diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..3f9e65d --- /dev/null +++ b/.jshintrc @@ -0,0 +1,13 @@ +{ + "node": true, + "camelcase" : true, + "eqnull" : true, + "indent": 2, + "undef": true, + "quotmark": "single", + "maxlen": 80, + "trailing": true, + "newcap": true, + "nonew": true, + "undef": false +} diff --git a/README.md b/README.md index f0cc4cd..f962d0f 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,80 @@ Below is a simple LoopBack application. The explorer is mounted at `/explorer`. ```js var loopback = require('loopback'); var app = loopback(); -var explorer = require('loopback-explorer'); +var explorer = require('../'); +var port = 3000; var Product = loopback.Model.extend('product'); Product.attachTo(loopback.memory()); app.model(Product); +app.use('/explorer', explorer(app, {basePath: '/api'})); app.use('/api', loopback.rest()); -app.use('/explorer', explorer(app, { basePath: '/api' })); +console.log("Explorer mounted at localhost:" + port + "/explorer"); -app.listen(3000); +app.listen(port); ``` + +## Advanced Usage + +Many aspects of the explorer are configurable. + +See [options](#options) for a description of these options: + +```js +// Mount middleware before calling `explorer()` to add custom headers, auth, etc. +app.use('/explorer', loopback.basicAuth('user', 'password')); +app.use('/explorer', explorer(app, { + basePath: '/custom-api-root', + swaggerDistRoot: '/swagger', + apiInfo: { + 'title': 'My API', + 'description': 'Explorer example app.' + }, + resourcePath: 'swaggerResources', + version: '0.1-unreleasable' +})); +app.use('/custom-api-root', loopback.rest()); +``` + +## Options + +Options are passed to `explorer(app, options)`. + +`basePath`: **String** + +> Default: `app.get('restAPIRoot')` or `'/api'`. + +> Sets the API's base path. This must be set if you are mounting your api +> to a path different than '/api', e.g. with +> `loopback.use('/custom-api-root', loopback.rest()); + +`swaggerDistRoot`: **String** + +> Sets a path within your application for overriding Swagger UI files. + +> If present, will search `swaggerDistRoot` first when attempting to load Swagger UI, allowing +> you to pick and choose overrides to the interface. Use this to style your explorer or +> add additional functionality. + +> See [index.html](public/index.html), where you may want to begin your overrides. +> The rest of the UI is provided by [Swagger UI](https://github.com/wordnik/swagger-ui). + +`apiInfo`: **Object** + +> Additional information about your API. See the +> [spec](https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#513-info-object). + +`resourcePath`: **String** + +> Default: `'resources'` + +> Sets a different path for the +> [resource listing](https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#51-resource-listing). +> You generally shouldn't have to change this. + +`version`: **String** + +> Default: Read from package.json + +> Sets your API version. If not present, will read from your app's package.json. diff --git a/example/simple.js b/example/simple.js index 0b8d8c2..3705ca5 100644 --- a/example/simple.js +++ b/example/simple.js @@ -1,12 +1,19 @@ var loopback = require('loopback'); var app = loopback(); var explorer = require('../'); +var port = 3000; -var Product = loopback.Model.extend('product'); +var Product = loopback.Model.extend('product', { + foo: {type: 'string', required: true}, + bar: 'string', + aNum: {type: 'number', min: 1, max: 10, required: true, default: 5} +}); Product.attachTo(loopback.memory()); app.model(Product); -app.use(loopback.rest()); -app.use('/explorer', explorer(app)); +var apiPath = '/api'; +app.use('/explorer', explorer(app, {basePath: apiPath})); +app.use(apiPath, loopback.rest()); +console.log('Explorer mounted at localhost:' + port + '/explorer'); -app.listen(3000); +app.listen(port); diff --git a/index.js b/index.js index 8d9428e..5062627 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,15 @@ + 'use strict'; /*! * Adds dynamically-updated docs as /explorer */ +var url = require('url'); var path = require('path'); -var extend = require('util')._extend; -var loopback = require('loopback'); -var express = requireLoopbackDependency('express'); +var urlJoin = require('./lib/url-join'); +var _defaults = require('lodash.defaults'); +var express = require('express'); +var swagger = require('./lib/swagger'); +var SWAGGER_UI_ROOT = path.join(__dirname, 'node_modules', + 'swagger-ui', 'dist'); var STATIC_ROOT = path.join(__dirname, 'public'); module.exports = explorer; @@ -17,41 +22,43 @@ module.exports = explorer; */ function explorer(loopbackApplication, options) { - options = extend({}, options); - options.basePath = options.basePath || loopbackApplication.get('restApiRoot'); - - loopbackApplication.docs(options); + options = _defaults({}, options, { + resourcePath: 'resources', + apiInfo: loopbackApplication.get('apiInfo') || {} + }); var app = express(); + swagger(loopbackApplication, app, options); + app.disable('x-powered-by'); + // config.json is loaded by swagger-ui. The server should respond + // with the relative URI of the resource doc. app.get('/config.json', function(req, res) { + // Get the path we're mounted at. It's best to get this from the referer + // in case we're proxied at a deep path. + var source = url.parse(req.headers.referer || '').pathname; + // If no referer is available, use the incoming url. + if (!source) { + source = req.originalUrl.replace(/\/config.json(\?.*)?$/, ''); + } res.send({ - discoveryUrl: (options.basePath || '') + '/swagger/resources' + url: urlJoin(source, '/' + options.resourcePath) }); }); - app.use(loopback.static(STATIC_ROOT)); + + // Allow specifying a static file root for swagger files. Any files in + // that folder will override those in the swagger-ui distribution. + // In this way one could e.g. make changes to index.html without having + // to worry about constantly pulling in JS updates. + if (options.swaggerDistRoot) { + app.use(express.static(options.swaggerDistRoot)); + } + // File in node_modules are overridden by a few customizations + app.use(express.static(STATIC_ROOT)); + // Swagger UI distribution + app.use(express.static(SWAGGER_UI_ROOT)); + return app; } - -function requireLoopbackDependency(module) { - try { - return require('loopback/node_modules/' + module); - } catch (err) { - if (err.code !== 'MODULE_NOT_FOUND') throw err; - try { - // Dependencies may be installed outside the loopback module, - // e.g. as peer dependencies. Try to load the dependency from there. - return require(module); - } catch (errPeer) { - if (errPeer.code !== 'MODULE_NOT_FOUND') throw errPeer; - // Rethrow the initial error to make it clear that we were trying - // to load a module that should have been installed inside - // "loopback/node_modules". This should minimise end-user's confusion. - // However, such situation should never happen as `require('loopback')` - // would have failed before this function was even called. - throw err; - } - } -} diff --git a/lib/class-helper.js b/lib/class-helper.js new file mode 100644 index 0000000..ff788d9 --- /dev/null +++ b/lib/class-helper.js @@ -0,0 +1,47 @@ +'use strict'; + +/** + * Module dependencies. + */ +var modelHelper = require('./model-helper'); +var urlJoin = require('./url-join'); + +/** + * Export the classHelper singleton. + */ +var classHelper = module.exports = { + /** + * Given a remoting class, generate an API doc. + * See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#52-api-declaration + * @param {Class} aClass Strong Remoting class. + * @param {Object} opts Options (passed from Swagger(remotes, options)) + * @param {String} opts.version API Version. + * @param {String} opts.swaggerVersion Swagger version. + * @param {String} opts.basePath Basepath (usually e.g. http://localhost:3000). + * @param {String} opts.resourcePath Resource path (usually /swagger/resources). + * @return {Object} API Declaration. + */ + generateAPIDoc: function(aClass, opts) { + return { + apiVersion: opts.version, + swaggerVersion: opts.swaggerVersion, + basePath: opts.basePath, + resourcePath: urlJoin('/', opts.resourcePath), + apis: [], + models: modelHelper.generateModelDefinition(aClass) + }; + }, + /** + * Given a remoting class, generate a reference to an API declaration. + * This is meant for insertion into the Resource declaration. + * See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#512-resource-object + * @param {Class} aClass Strong Remoting class. + * @return {Object} API declaration reference. + */ + generateResourceDocAPIEntry: function(aClass) { + return { + path: aClass.http.path, + description: aClass.ctor.sharedCtor && aClass.ctor.sharedCtor.description + }; + } +}; diff --git a/lib/model-helper.js b/lib/model-helper.js new file mode 100644 index 0000000..3b79297 --- /dev/null +++ b/lib/model-helper.js @@ -0,0 +1,101 @@ +'use strict'; + +/** + * Module dependencies. + */ +var _cloneDeep = require('lodash.clonedeep'); +var translateDataTypeKeys = require('./translate-data-type-keys'); + +/** + * Export the modelHelper singleton. + */ +var modelHelper = module.exports = { + /** + * Given a class (from remotes.classes()), generate a model definition. + * This is used to generate the schema at the top of many endpoints. + * @param {Class} class Remote class. + * @return {Object} Associated model definition. + */ + generateModelDefinition: function generateModelDefinition(aClass) { + var def = aClass.ctor.definition; + var name = def.name; + + var required = []; + // Don't modify original properties. + var properties = _cloneDeep(def.properties); + + // Iterate through each property in the model definition. + // Types may be defined as constructors (e.g. String, Date, etc.), + // or as strings; getPropType() will take care of the conversion. + // See more on types: + // https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#431-primitives + Object.keys(properties).forEach(function(key) { + var prop = properties[key]; + + // Eke a type out of the constructors we were passed. + prop = modelHelper.LDLPropToSwaggerDataType(prop); + + // Required props sit in a per-model array. + if (prop.required || (prop.id && !prop.generated)) { + required.push(key); + } + + // Change mismatched keys. + prop = translateDataTypeKeys(prop); + + // Assign this back to the properties object. + properties[key] = prop; + }); + + var out = {}; + out[name] = { + id: name, + properties: properties, + required: required + }; + return out; + }, + + /** + * Given a propType (which may be a function, string, or array), + * get a string type. + * @param {*} propType Prop type description. + * @return {String} Prop type string. + */ + getPropType: function getPropType(propType) { + if (typeof propType === 'function') { + propType = propType.name.toLowerCase(); + } else if(Array.isArray(propType)) { + propType = 'array'; + } + return propType; + }, + + // Converts a prop defined with the LDL spec to one conforming to the + // Swagger spec. + // https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#431-primitives + LDLPropToSwaggerDataType: function LDLPropToSwaggerDataType(prop) { + var out = _cloneDeep(prop); + out.type = modelHelper.getPropType(out.type); + + if (out.type === 'array') { + var arrayProp = prop.type[0]; + if (!arrayProp.type) arrayProp = {type: arrayProp}; + out.items = modelHelper.LDLPropToSwaggerDataType(arrayProp); + } + + if (out.type === 'date') { + out.type = 'string'; + out.format = 'date'; + } else if (out.type === 'buffer') { + out.type = 'string'; + out.format = 'byte'; + } else if (out.type === 'number') { + out.format = 'double'; // Since all JS numbers are doubles + } + return out; + } +}; + + + diff --git a/lib/route-helper.js b/lib/route-helper.js new file mode 100644 index 0000000..80bf9a8 --- /dev/null +++ b/lib/route-helper.js @@ -0,0 +1,240 @@ +'use strict'; + +/** + * Module dependencies. + */ + +var debug = require('debug')('loopback:explorer:routeHelpers'); +var _cloneDeep = require('lodash.clonedeep'); +var translateDataTypeKeys = require('./translate-data-type-keys'); +var modelHelper = require('./model-helper'); + +/** + * Export the routeHelper singleton. + */ +var routeHelper = module.exports = { + /** + * Given a route, generate an API description and add it to the doc. + * If a route shares a path with another route (same path, different verb), + * add it as a new operation under that API description. + * + * Routes can be translated to API declaration 'operations', + * but they need a little massaging first. The `accepts` and + * `returns` declarations need some basic conversions to be compatible. + * + * This method will convert the route and add it to the doc. + * @param {Route} route Strong Remoting Route object. + * @param {Class} classDef Strong Remoting class. + * @param {Object} doc The class's backing API declaration doc. + */ + addRouteToAPIDeclaration: function (route, classDef, doc) { + var api = routeHelper.routeToAPIDoc(route, classDef); + var matchingAPIs = doc.apis.filter(function(existingAPI) { + return existingAPI.path === api.path; + }); + if (matchingAPIs.length) { + matchingAPIs[0].operations.push(api.operations[0]); + } else { + doc.apis.push(api); + } + }, + + /** + * Massage route.accepts. + * @param {Object} route Strong Remoting Route object. + * @param {Class} classDef Strong Remoting class. + * @return {Array} Array of param docs. + */ + convertAcceptsToSwagger: function convertAcceptsToSwagger(route, classDef) { + var split = route.method.split('.'); + var accepts = _cloneDeep(route.accepts) || []; + if (classDef && classDef.sharedCtor && + classDef.sharedCtor.accepts && split.length > 2 /* HACK */) { + accepts = accepts.concat(classDef.sharedCtor.accepts); + } + + // Filter out parameters that are generated from the incoming request, + // or generated by functions that use those resources. + accepts = accepts.filter(function(arg){ + if (!arg.http) return true; + // Don't show derived arguments. + if (typeof arg.http === 'function') return false; + // Don't show arguments set to the incoming http request. + // Please note that body needs to be shown, such as User.create(). + if (arg.http.source === 'req') return false; + return true; + }); + + // Translate LDL keys to Swagger keys. + accepts = accepts.map(translateDataTypeKeys); + + // Turn accept definitions in to parameter docs. + accepts = accepts.map(routeHelper.acceptToParameter(route)); + + return accepts; + }, + + /** + * Massage route.returns. + * @param {Object} route Strong Remoting Route object. + * @param {Class} classDef Strong Remoting class. + * @return {Object} A single returns param doc. + */ + convertReturnsToSwagger: function convertReturnsToSwagger(route, classDef) { + var routeReturns = _cloneDeep(route.returns) || []; + // HACK: makes autogenerated REST routes return the correct model name. + var firstReturn = routeReturns && routeReturns[0]; + if (firstReturn && firstReturn.arg === 'data') { + if (firstReturn.type === 'object') { + firstReturn.type = classDef.name; + } else if (firstReturn.type === 'array') { + firstReturn.type = 'array'; + firstReturn.items = { + '$ref': classDef.name + }; + } + } + + // Translate LDL keys to Swagger keys. + var returns = routeReturns.map(translateDataTypeKeys); + + // Convert `returns` into a single object for later conversion into an + // operation object. + if (returns && returns.length > 1) { + // TODO ad-hoc model definition in the case of multiple return values. + returns = {model: 'object'}; + } else { + returns = returns[0] || {}; + } + + return returns; + }, + + /** + * Converts from an sl-remoting-formatted "Route" description to a + * Swagger-formatted "API" description. + * See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#523-operation-object + */ + routeToAPIDoc: function routeToAPIDoc(route, classDef) { + var returnDesc; + + // Some parameters need to be altered; eventually most of this should + // be removed. + var accepts = routeHelper.convertAcceptsToSwagger(route, classDef); + var returns = routeHelper.convertReturnsToSwagger(route, classDef); + + debug('route %j', route); + + var apiDoc = { + path: routeHelper.convertPathFragments(route.path), + operations: [{ + method: routeHelper.convertVerb(route.verb), + // [rfeng] Swagger UI doesn't escape '.' for jQuery selector + nickname: route.method.replace(/\./g, '_'), + // Per the spec: + // https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#523-operation-object + // This is the only object that may have a type of 'void'. + type: returns.model || returns.type || 'void', + parameters: accepts, + // TODO(schoon) - We don't have descriptions for this yet. + responseMessages: [], + summary: route.description, // TODO(schoon) - Excerpt? + notes: '' // TODO(schoon) - `description` metadata? + }] + }; + // Convert types and return. + return routeHelper.extendWithType(apiDoc); + }, + + convertPathFragments: function convertPathFragments(path) { + return path.split('/').map(function (fragment) { + if (fragment.charAt(0) === ':') { + return '{' + fragment.slice(1) + '}'; + } + return fragment; + }).join('/'); + }, + + convertVerb: function convertVerb(verb) { + if (verb.toLowerCase() === 'all') { + return 'POST'; + } + + if (verb.toLowerCase() === 'del') { + return 'DELETE'; + } + + return verb.toUpperCase(); + }, + + /** + * A generator to convert from an sl-remoting-formatted "Accepts" description + * to a Swagger-formatted "Parameter" description. + */ + acceptToParameter: function acceptToParameter(route) { + var type = 'form'; + + if (route.verb.toLowerCase() === 'get') { + type = 'query'; + } + + return function (accepts) { + var name = accepts.name || accepts.arg; + var paramType = type; + + // TODO: Regex. This is leaky. + if (route.path.indexOf(':' + name) !== -1) { + paramType = 'path'; + } + + // Check the http settings for the argument + if(accepts.http && accepts.http.source) { + paramType = accepts.http.source; + } + + var out = { + paramType: paramType || type, + name: name, + description: accepts.description, + type: accepts.type, + required: !!accepts.required, + defaultValue: accepts.defaultValue, + minimum: accepts.minimum, + maximum: accepts.maximum, + allowMultiple: false + }; + + out = routeHelper.extendWithType(out); + + // HACK: Derive the type from model + if(out.name === 'data' && out.type === 'object') { + out.type = route.method.split('.')[0]; + } + + return out; + }; + }, + + /** + * Extends an Operation Object or Parameter object with + * a proper Swagger type and optional `format` and `items` fields. + * Does not modify original object. + * @param {Object} obj Object to extend. + * @return {Object} Extended object. + */ + extendWithType: function extendWithType(obj) { + obj = _cloneDeep(obj); + + // Format the `type` property using our LDL converter. + var typeDesc = modelHelper + .LDLPropToSwaggerDataType({type: obj.model || obj.type}); + // The `typeDesc` may have additional attributes, such as + // `format` for non-primitive types. + Object.keys(typeDesc).forEach(function(key){ + obj[key] = typeDesc[key]; + }); + return obj; + } +}; + + diff --git a/lib/swagger.js b/lib/swagger.js new file mode 100644 index 0000000..c886ac3 --- /dev/null +++ b/lib/swagger.js @@ -0,0 +1,141 @@ +'use strict'; +/** + * Expose the `Swagger` plugin. + */ +module.exports = Swagger; + +/** + * Module dependencies. + */ +var debug = require('debug')('loopback:explorer:swagger'); +var path = require('path'); +var urlJoin = require('./url-join'); +var _defaults = require('lodash.defaults'); +var classHelper = require('./class-helper'); +var modelHelper = require('./model-helper'); +var routeHelper = require('./route-helper'); + +/** + * Create a remotable Swagger module for plugging into `RemoteObjects`. + * + * @param {Application} loopbackApplication Host loopback application. + * @param {Application} swaggerApp Swagger application used for hosting + * these files. + * @param {Object} opts Options. + */ +function Swagger(loopbackApplication, swaggerApp, opts) { + opts = _defaults({}, opts, { + swaggerVersion: '1.2', + basePath: loopbackApplication.get('restApiRoot') || '/api', + resourcePath: 'resources', + version: getVersion() + }); + + // We need a temporary REST adapter to discover our available routes. + var remotes = loopbackApplication.remotes(); + var adapter = remotes.handler('rest').adapter; + var routes = adapter.allRoutes(); + var classes = remotes.classes(); + + // These are the docs we will be sending from the /swagger endpoints. + var resourceDoc = generateResourceDoc(opts); + var apiDocs = {}; + + // A class is an endpoint root; e.g. /users, /products, and so on. + classes.forEach(function (aClass) { + var doc = apiDocs[aClass.name] = classHelper.generateAPIDoc(aClass, opts); + resourceDoc.apis.push(classHelper.generateResourceDocAPIEntry(aClass)); + + // Add the getter for this doc. + var docPath = urlJoin(opts.resourcePath, aClass.http.path); + addRoute(swaggerApp, docPath, doc); + }); + + // A route is an endpoint, such as /users/findOne. + routes.forEach(function(route) { + // Get the API doc matching this class name. + var className = route.method.split('.')[0]; + var doc = apiDocs[className]; + if (!doc) { + console.error('Route exists with no class: %j', route); + return; + } + // Get the class definition matching this route. + var classDef = classes.filter(function (item) { + return item.name === className; + })[0]; + + routeHelper.addRouteToAPIDeclaration(route, classDef, doc); + }); + + /** + * The topmost Swagger resource is a description of all (non-Swagger) + * resources available on the system, and where to find more + * information about them. + */ + addRoute(swaggerApp, opts.resourcePath, resourceDoc); +} + +/** + * Add a route to this remoting extension. + * @param {Application} app Express application. + * @param {String} uri Path from which to serve the doc. + * @param {Object} doc Doc to serve. + */ +function addRoute(app, uri, doc) { + + var hasBasePath = Object.keys(doc).indexOf('basePath') !== -1; + var initialPath = doc.basePath || ''; + + app.get(urlJoin('/', uri), function(req, res) { + + // There's a few forces at play that require this "hack". The Swagger spec + // requires a `basePath` to be set in the API descriptions. However, we + // can't guarantee this path is either reachable or desirable if it's set + // as a part of the options. + // + // The simplest way around this is to reflect the value of the `Host` HTTP + // header as the `basePath`. Because we pre-build the Swagger data, we don't + // know that header at the time the data is built. + if (hasBasePath) { + var headers = req.headers; + var host = headers.Host || headers.host; + doc.basePath = req.protocol + '://' + host + initialPath; + } + res.send(200, doc); + }); +} + +/** + * Generate a top-level resource doc. This is the entry point for swagger UI + * and lists all of the available APIs. + * @param {Object} opts Swagger options. + * @return {Object} Resource doc. + */ +function generateResourceDoc(opts) { + return { + swaggerVersion: opts.swaggerVersion, + apiVersion: opts.version, + apis: [], + // See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#513-info-object + info: opts.apiInfo + // TODO Authorizations + // https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#514-authorizations-object + // TODO Produces/Consumes + // https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#52-api-declaration + }; +} + +/** + * Attempt to get the current API version from package.json. + * @return {String} API Version. + */ +function getVersion() { + var version; + try { + version = require(path.join(process.cwd(), 'package.json')).version; + } catch(e) { + version = ''; + } + return version; +} diff --git a/lib/translate-data-type-keys.js b/lib/translate-data-type-keys.js new file mode 100644 index 0000000..a47c85d --- /dev/null +++ b/lib/translate-data-type-keys.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Module dependencies. + */ + +var _cloneDeep = require('lodash.clonedeep'); + +// Keys that are different between LDL and Swagger +var KEY_TRANSLATIONS = { + // LDL : Swagger + 'doc': 'description', + 'default': 'defaultValue', + 'min': 'minimum', + 'max': 'maximum' +}; + +/** + * Correct key mismatches between LDL & Swagger. + * Does not modify original object. + * @param {Object} object Object on which to change keys. + * @return {Object} Translated object. + */ +module.exports = function translateDataTypeKeys(object) { + object = _cloneDeep(object); + Object.keys(KEY_TRANSLATIONS).forEach(function(LDLKey){ + var val = object[LDLKey]; + if (val) { + // Should change in Swagger 2.0 + if (LDLKey === 'min' || LDLKey === 'max') { + val = String(val); + } + object[KEY_TRANSLATIONS[LDLKey]] = val; + } + delete object[LDLKey]; + }); + return object; +}; diff --git a/lib/url-join.js b/lib/url-join.js new file mode 100644 index 0000000..4805191 --- /dev/null +++ b/lib/url-join.js @@ -0,0 +1,8 @@ +'use strict'; + +// Simple url joiner. Ensure we don't have to care about whether or not +// we are fed paths with leading/trailing slashes. +module.exports = function urlJoin() { + var args = Array.prototype.slice.call(arguments); + return args.join('/').replace(/\/+/g, '/'); +}; diff --git a/package.json b/package.json index 9063573..d9a723d 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,11 @@ { "name": "loopback-explorer", - "version": "1.1.1", + "version": "1.2.0", "description": "Browse and test your LoopBack app's APIs", "main": "index.js", "scripts": { "test": "mocha" }, - "peerDependencies": { - "loopback": "2.x || 1.x >=1.5" - }, "repository": { "type": "git", "url": "git://github.com/strongloop/loopback-explorer.git" @@ -25,12 +22,19 @@ }, "devDependencies": { "loopback": "1.x", - "mocha": "~1.14.0", - "supertest": "~0.8.1", - "chai": "~1.8.1" + "mocha": "~1.20.1", + "supertest": "~0.13.0", + "chai": "~1.9.1" }, "license": { "name": "Dual MIT/StrongLoop", "url": "https://github.com/strongloop/loopback-explorer/blob/master/LICENSE" + }, + "dependencies": { + "swagger-ui": "~2.0.18", + "debug": "~1.0.3", + "lodash.clonedeep": "^2.4.1", + "lodash.defaults": "^2.4.1", + "express": "3.x" } } diff --git a/public/css/hightlight.default.css b/public/css/hightlight.default.css deleted file mode 100644 index e417fc1..0000000 --- a/public/css/hightlight.default.css +++ /dev/null @@ -1,135 +0,0 @@ -/* - -Original style from softwaremaniacs.org (c) Ivan Sagalaev - -*/ - -pre code { - display: block; padding: 0.5em; - background: #F0F0F0; -} - -pre code, -pre .subst, -pre .tag .title, -pre .lisp .title, -pre .clojure .built_in, -pre .nginx .title { - color: black; -} - -pre .string, -pre .title, -pre .constant, -pre .parent, -pre .tag .value, -pre .rules .value, -pre .rules .value .number, -pre .preprocessor, -pre .ruby .symbol, -pre .ruby .symbol .string, -pre .aggregate, -pre .template_tag, -pre .django .variable, -pre .smalltalk .class, -pre .addition, -pre .flow, -pre .stream, -pre .bash .variable, -pre .apache .tag, -pre .apache .cbracket, -pre .tex .command, -pre .tex .special, -pre .erlang_repl .function_or_atom, -pre .markdown .header { - color: #800; -} - -pre .comment, -pre .annotation, -pre .template_comment, -pre .diff .header, -pre .chunk, -pre .markdown .blockquote { - color: #888; -} - -pre .number, -pre .date, -pre .regexp, -pre .literal, -pre .smalltalk .symbol, -pre .smalltalk .char, -pre .go .constant, -pre .change, -pre .markdown .bullet, -pre .markdown .link_url { - color: #080; -} - -pre .label, -pre .javadoc, -pre .ruby .string, -pre .decorator, -pre .filter .argument, -pre .localvars, -pre .array, -pre .attr_selector, -pre .important, -pre .pseudo, -pre .pi, -pre .doctype, -pre .deletion, -pre .envvar, -pre .shebang, -pre .apache .sqbracket, -pre .nginx .built_in, -pre .tex .formula, -pre .erlang_repl .reserved, -pre .prompt, -pre .markdown .link_label, -pre .vhdl .attribute, -pre .clojure .attribute, -pre .coffeescript .property { - color: #88F -} - -pre .keyword, -pre .id, -pre .phpdoc, -pre .title, -pre .built_in, -pre .aggregate, -pre .css .tag, -pre .javadoctag, -pre .phpdoc, -pre .yardoctag, -pre .smalltalk .class, -pre .winutils, -pre .bash .variable, -pre .apache .tag, -pre .go .typename, -pre .tex .command, -pre .markdown .strong, -pre .request, -pre .status { - font-weight: bold; -} - -pre .markdown .emphasis { - font-style: italic; -} - -pre .nginx .built_in { - font-weight: normal; -} - -pre .coffeescript .javascript, -pre .javascript .xml, -pre .tex .formula, -pre .xml .javascript, -pre .xml .vbscript, -pre .xml .css, -pre .xml .cdata { - opacity: 0.5; -} diff --git a/public/css/loopbackStyles.css b/public/css/loopbackStyles.css new file mode 100644 index 0000000..8065c59 --- /dev/null +++ b/public/css/loopbackStyles.css @@ -0,0 +1,27 @@ +/* Styles used for loopback explorer customizations */ +.accessTokenDisplay { + color: white; + margin-right: 10px; +} +.accessTokenDisplay.set { + border-bottom: 1px dotted #333; position: relative; cursor: pointer; +} +.accessTokenDisplay.set:hover:after { + content: attr(data-tooltip); + position: absolute; + white-space: nowrap; + font-size: 12px; + background: rgba(0, 0, 0, 0.85); + padding: 3px 7px; + color: #FFF; + border-radius: 3px; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + right: 0; + bottom: -30px; +} + +/* +FIXME: Separate the overrides from the rest of the styles, rather than override screen.css entirely. +*/ + diff --git a/public/css/screen.css b/public/css/screen.css index ce7d8fb..b8731a7 100644 --- a/public/css/screen.css +++ b/public/css/screen.css @@ -1,3 +1,4 @@ +/* FIXME move overrides only into loopbackStyles.css */ html, body, div, @@ -132,24 +133,7 @@ section, summary { display: block; } -@font-face { - font-family: 'Ubuntu'; - font-style: normal; - font-weight: 300; - src: local('Ubuntu Light'), local('Ubuntu-Light'), url(../fonts/_aijTyevf54tkVDLy-dlnLO3LdcAZYWl9Si6vvxL-qU.woff) format('woff'); -} -@font-face { - font-family: 'Ubuntu'; - font-style: normal; - font-weight: 500; - src: local('Ubuntu Medium'), local('Ubuntu-Medium'), url(../fonts/OsJ2DjdpjqFRVUSto6IffLO3LdcAZYWl9Si6vvxL-qU.woff) format('woff'); -} -@font-face { - font-family: 'Ubuntu'; - font-style: normal; - font-weight: 700; - src: local('Ubuntu Bold'), local('Ubuntu-Bold'), url(../fonts/0ihfXUL2emPh0ROJezvraLO3LdcAZYWl9Si6vvxL-qU.woff) format('woff'); -} + h1 a, h2 a, h3 a, diff --git a/public/index.html b/public/index.html index 03bd2c3..e5b6ff6 100644 --- a/public/index.html +++ b/public/index.html @@ -1,82 +1,47 @@ - StrongLoop API Explorer - - - - - - - - - - - - - + StrongLoop API Explorer + + + + + + + + + + + + + + + + + - - function loadSwaggerUi(config) { - window.swaggerUi = new SwaggerUi({ - discoveryUrl: config.discoveryUrl || "/swagger/resources", - apiKey: "", - dom_id: "swagger-ui-container", - supportHeaderParams: true, - supportedSubmitMethods: ['get', 'post', 'put', 'delete'], - onComplete: function(swaggerApi, swaggerUi) { - if (console) { - console.log("Loaded SwaggerUI") - console.log(swaggerApi); - console.log(swaggerUi); - } - $('pre code').each(function(i, e) {hljs.highlightBlock(e)}); - }, - onFailure: function(data) { - if (console) { - console.log("Unable to Load SwaggerUI"); - console.log(data); - } - }, - docExpansion: "none" - }); - - window.swaggerUi.load(); - } - + + - + - -
-   -
- -
- +
+ +
+
+ Token Not Set + +
+ +
+
+
 
+
- diff --git a/public/lib/backbone-min.js b/public/lib/backbone-min.js deleted file mode 100644 index c1c0d4f..0000000 --- a/public/lib/backbone-min.js +++ /dev/null @@ -1,38 +0,0 @@ -// Backbone.js 0.9.2 - -// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. -// Backbone may be freely distributed under the MIT license. -// For all details and documentation: -// http://backbonejs.org -(function(){var l=this,y=l.Backbone,z=Array.prototype.slice,A=Array.prototype.splice,g;g="undefined"!==typeof exports?exports:l.Backbone={};g.VERSION="0.9.2";var f=l._;!f&&"undefined"!==typeof require&&(f=require("underscore"));var i=l.jQuery||l.Zepto||l.ender;g.setDomLibrary=function(a){i=a};g.noConflict=function(){l.Backbone=y;return this};g.emulateHTTP=!1;g.emulateJSON=!1;var p=/\s+/,k=g.Events={on:function(a,b,c){var d,e,f,g,j;if(!b)return this;a=a.split(p);for(d=this._callbacks||(this._callbacks= -{});e=a.shift();)f=(j=d[e])?j.tail:{},f.next=g={},f.context=c,f.callback=b,d[e]={tail:g,next:j?j.next:f};return this},off:function(a,b,c){var d,e,h,g,j,q;if(e=this._callbacks){if(!a&&!b&&!c)return delete this._callbacks,this;for(a=a?a.split(p):f.keys(e);d=a.shift();)if(h=e[d],delete e[d],h&&(b||c))for(g=h.tail;(h=h.next)!==g;)if(j=h.callback,q=h.context,b&&j!==b||c&&q!==c)this.on(d,j,q);return this}},trigger:function(a){var b,c,d,e,f,g;if(!(d=this._callbacks))return this;f=d.all;a=a.split(p);for(g= -z.call(arguments,1);b=a.shift();){if(c=d[b])for(e=c.tail;(c=c.next)!==e;)c.callback.apply(c.context||this,g);if(c=f){e=c.tail;for(b=[b].concat(g);(c=c.next)!==e;)c.callback.apply(c.context||this,b)}}return this}};k.bind=k.on;k.unbind=k.off;var o=g.Model=function(a,b){var c;a||(a={});b&&b.parse&&(a=this.parse(a));if(c=n(this,"defaults"))a=f.extend({},c,a);b&&b.collection&&(this.collection=b.collection);this.attributes={};this._escapedAttributes={};this.cid=f.uniqueId("c");this.changed={};this._silent= -{};this._pending={};this.set(a,{silent:!0});this.changed={};this._silent={};this._pending={};this._previousAttributes=f.clone(this.attributes);this.initialize.apply(this,arguments)};f.extend(o.prototype,k,{changed:null,_silent:null,_pending:null,idAttribute:"id",initialize:function(){},toJSON:function(){return f.clone(this.attributes)},get:function(a){return this.attributes[a]},escape:function(a){var b;if(b=this._escapedAttributes[a])return b;b=this.get(a);return this._escapedAttributes[a]=f.escape(null== -b?"":""+b)},has:function(a){return null!=this.get(a)},set:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c||(c={});if(!d)return this;d instanceof o&&(d=d.attributes);if(c.unset)for(e in d)d[e]=void 0;if(!this._validate(d,c))return!1;this.idAttribute in d&&(this.id=d[this.idAttribute]);var b=c.changes={},h=this.attributes,g=this._escapedAttributes,j=this._previousAttributes||{};for(e in d){a=d[e];if(!f.isEqual(h[e],a)||c.unset&&f.has(h,e))delete g[e],(c.silent?this._silent: -b)[e]=!0;c.unset?delete h[e]:h[e]=a;!f.isEqual(j[e],a)||f.has(h,e)!=f.has(j,e)?(this.changed[e]=a,c.silent||(this._pending[e]=!0)):(delete this.changed[e],delete this._pending[e])}c.silent||this.change(c);return this},unset:function(a,b){(b||(b={})).unset=!0;return this.set(a,null,b)},clear:function(a){(a||(a={})).unset=!0;return this.set(f.clone(this.attributes),a)},fetch:function(a){var a=a?f.clone(a):{},b=this,c=a.success;a.success=function(d,e,f){if(!b.set(b.parse(d,f),a))return!1;c&&c(b,d)}; -a.error=g.wrapError(a.error,b,a);return(this.sync||g.sync).call(this,"read",this,a)},save:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c=c?f.clone(c):{};if(c.wait){if(!this._validate(d,c))return!1;e=f.clone(this.attributes)}a=f.extend({},c,{silent:!0});if(d&&!this.set(d,c.wait?a:c))return!1;var h=this,i=c.success;c.success=function(a,b,e){b=h.parse(a,e);if(c.wait){delete c.wait;b=f.extend(d||{},b)}if(!h.set(b,c))return false;i?i(h,a):h.trigger("sync",h,a,c)};c.error=g.wrapError(c.error, -h,c);b=this.isNew()?"create":"update";b=(this.sync||g.sync).call(this,b,this,c);c.wait&&this.set(e,a);return b},destroy:function(a){var a=a?f.clone(a):{},b=this,c=a.success,d=function(){b.trigger("destroy",b,b.collection,a)};if(this.isNew())return d(),!1;a.success=function(e){a.wait&&d();c?c(b,e):b.trigger("sync",b,e,a)};a.error=g.wrapError(a.error,b,a);var e=(this.sync||g.sync).call(this,"delete",this,a);a.wait||d();return e},url:function(){var a=n(this,"urlRoot")||n(this.collection,"url")||t(); -return this.isNew()?a:a+("/"==a.charAt(a.length-1)?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this.attributes)},isNew:function(){return null==this.id},change:function(a){a||(a={});var b=this._changing;this._changing=!0;for(var c in this._silent)this._pending[c]=!0;var d=f.extend({},a.changes,this._silent);this._silent={};for(c in d)this.trigger("change:"+c,this,this.get(c),a);if(b)return this;for(;!f.isEmpty(this._pending);){this._pending= -{};this.trigger("change",this,a);for(c in this.changed)!this._pending[c]&&!this._silent[c]&&delete this.changed[c];this._previousAttributes=f.clone(this.attributes)}this._changing=!1;return this},hasChanged:function(a){return!arguments.length?!f.isEmpty(this.changed):f.has(this.changed,a)},changedAttributes:function(a){if(!a)return this.hasChanged()?f.clone(this.changed):!1;var b,c=!1,d=this._previousAttributes,e;for(e in a)if(!f.isEqual(d[e],b=a[e]))(c||(c={}))[e]=b;return c},previous:function(a){return!arguments.length|| -!this._previousAttributes?null:this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},isValid:function(){return!this.validate(this.attributes)},_validate:function(a,b){if(b.silent||!this.validate)return!0;var a=f.extend({},this.attributes,a),c=this.validate(a,b);if(!c)return!0;b&&b.error?b.error(this,c,b):this.trigger("error",this,c,b);return!1}});var r=g.Collection=function(a,b){b||(b={});b.model&&(this.model=b.model);b.comparator&&(this.comparator=b.comparator); -this._reset();this.initialize.apply(this,arguments);a&&this.reset(a,{silent:!0,parse:b.parse})};f.extend(r.prototype,k,{model:o,initialize:function(){},toJSON:function(a){return this.map(function(b){return b.toJSON(a)})},add:function(a,b){var c,d,e,g,i,j={},k={},l=[];b||(b={});a=f.isArray(a)?a.slice():[a];c=0;for(d=a.length;c=b))this.iframe=i('