From 1bf7c27a56e3857703d22f8cf5e5ca8ca2957f68 Mon Sep 17 00:00:00 2001 From: Shelby Sanders Date: Thu, 2 Jul 2015 11:03:27 -0700 Subject: [PATCH] Merge remote-tracking branch 'upstream/master' into validate_param_enum --- .gitignore | 3 + CHANGES.md | 313 +++++++++++++++++++ CONTRIBUTING.md | 2 +- README.md | 21 +- example/simple.js | 4 +- index.js | 22 +- lib/class-helper.js | 14 +- lib/model-helper.js | 106 ++++--- lib/route-helper.js | 109 ++++--- lib/swagger.js | 120 +++++-- lib/translate-data-type-keys.js | 3 +- lib/type-converter.js | 14 + package.json | 18 +- public/css/loopbackStyles.css | 11 + public/index.html | 43 ++- public/lib/loadSwaggerUI.js | 29 +- test/class-helper.test.js | 50 ++- test/explorer.test.js | 84 ++++- test/fixtures/dummy-swagger-ui/index.html | 1 + test/fixtures/dummy-swagger-ui/swagger-ui.js | 1 + test/model-helper.test.js | 85 +++-- test/route-helper.test.js | 174 ++++++++++- test/swagger.test.js | 301 +++++++++++++++++- 23 files changed, 1309 insertions(+), 219 deletions(-) create mode 100644 CHANGES.md create mode 100644 lib/type-converter.js create mode 100644 test/fixtures/dummy-swagger-ui/index.html create mode 100644 test/fixtures/dummy-swagger-ui/swagger-ui.js diff --git a/.gitignore b/.gitignore index 3e5ac87..5e487e2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ lib-cov *.out *.pid *.gz +.idea +*.iml +*.tgz .idea pids diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..62327b5 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,313 @@ +2015-02-23, Version 1.7.1 +========================= + + * Remove unused external font "Droid Sans". (Miroslav Bajtoš) + + +2015-02-17, Version 1.7.0 +========================= + + * Made API doc of class use the http.path of the class if available, or the name of the class as a fallback (gandrianakis) + + +2015-01-09, Version 1.6.4 +========================= + + * Prevent double slash in the resource URLs (Miroslav Bajtoš) + + * Allow `uiDirs` to be defined as a String (Simon Ho) + + * Save accessToken in localStorage. Fixes #47 (Samuel Reed) + + +2015-01-06, Version 1.6.3 +========================= + + * Fix bad CLA URL in CONTRIBUTING.md (Ryan Graham) + + * Add X-UA-Compatible tag (Nick Van Dyck) + + +2014-12-12, Version 1.6.2 +========================= + + * Move 200 response to `type` on the operation object. See #75. (Samuel Reed) + + +2014-12-08, Version 1.6.1 +========================= + + * Use full lodash instead of lodash components (Ryan Graham) + + +2014-12-02, Version 1.6.0 +========================= + + * Remove model name from nickname, swagger spec understands op context. (Samuel Reed) + + +2014-11-29, Version 1.5.2 +========================= + + * model-helper: ignore unknown property types (Miroslav Bajtoš) + + +2014-10-24, Version 1.5.1 +========================= + + + +2014-10-24, Version 1.5.0 +========================= + + * Add an option `uiDirs` (Miroslav Bajtoš) + + * swagger: honour X-Forwarded-Proto header (Miroslav Bajtoš) + + +2014-10-21, Version 1.4.0 +========================= + + * Bump version (Raymond Feng) + + * Add integration tests for included models (Miroslav Bajtoš) + + * route-helper: add `responseMessages` (Miroslav Bajtoš) + + * model-helper: support anonymous object types (Miroslav Bajtoš) + + * swagger: include models from accepts/returns args (Miroslav Bajtoš) + + * loopbackStyles: improve spacing in small window (Miroslav Bajtoš) + + * swagger: Deprecate `opts.swaggerVersion` (Miroslav Bajtoš) + + * swagger: use X-Forwarded-Host for basePath (Miroslav Bajtoš) + + * example: use PersistedModel instead of Model (Miroslav Bajtoš) + + * models: include model's `description` (Miroslav Bajtoš) + + * Refactor conversion of data types (Miroslav Bajtoš) + + * Move `convertText` to `typeConverter` (Miroslav Bajtoš) + + * Add support for `context` and `res` param types (Krishna Raman) + + * package: update devDependencies (Miroslav Bajtoš) + + * gitignore: add .idea, *.tgz, *.iml (Miroslav Bajtoš) + + * Support multi-line array `description` and `notes` (Miroslav Bajtoš) + + * Use `1.0.0` as the default app version. (Miroslav Bajtoš) + + * Extend `consumes` and `produces` metadata (Miroslav Bajtoš) + + * route-helper: include `notes` and `deprecated` (Miroslav Bajtoš) + + * Pull model description from ctor.settings first (Shelby Sanders) + + +2014-10-08, Version 1.3.0 +========================= + + * swagger: allow cross-origin requests (Miroslav Bajtoš) + + * Sort endpoints by letter. (Samuel Reed) + + * Add syntax highlighting styles & highlight threshold. (Samuel Reed) + + * Add contribution guidelines (Ryan Graham) + + +2014-09-22, Version 1.2.11 +========================== + + * Bump version (Raymond Feng) + + * Fix how the array of models is iterated (Raymond Feng) + + +2014-09-05, Version 1.2.10 +========================== + + * Bump version (Raymond Feng) + + * Make sure nested/referenced models in array are mapped to swagger (Clark Wang) + + * Make sure nested/referenced models are mapped to swagger (Raymond Feng) + + +2014-08-15, Version 1.2.9 +========================= + + * Bump version (Raymond Feng) + + * Newest Swagger UI requires application/x-www-form-urlencoded. (Samuel Reed) + + * Use `dist` property from swagger-ui package. (Samuel Reed) + + * Fixed undefined modelClass when using polymorphic relations (Navid Nikpour) + + +2014-08-08, Version 1.2.8 +========================= + + * Bump version (Raymond Feng) + + * Fix the type name for a property if model class is used (Raymond Feng) + + +2014-08-04, Version 1.2.7 +========================= + + * Bump version (Raymond Feng) + + * Set up default consumes/produces media types (Raymond Feng) + + * Fix the default opts (Raymond Feng) + + * Add required swagger 1.2 items property for property type array (Ritchie Martori) + + * Allow passing a custom protocol. (Samuel Reed) + + +2014-07-29, Version 1.2.6 +========================= + + * Bump version (Raymond Feng) + + * res.send deprecated - updated to res.status (Geoffroy) + + * Remove hidden properties from definition. (Samuel Reed) + + +2014-07-25, Version 1.2.5 +========================= + + * Bump version (Raymond Feng) + + * Ensure models from relations are included (Raymond Feng) + + +2014-07-22, Version 1.2.4 +========================= + + * model-helper: handle arrays with undefined items (Miroslav Bajtoš) + + +2014-07-22, Version 1.2.3 +========================= + + * model-helper: handle array types with no item type (Miroslav Bajtoš) + + +2014-07-20, Version 1.2.2 +========================= + + * Bump version (Raymond Feng) + + * Properly convert complex return types. (Samuel Reed) + + +2014-07-18, Version 1.2.1 +========================= + + * Bump version (Raymond Feng) + + * Fix up loopback.rest() model definition hack. (Samuel Reed) + + +2014-07-14, Version 1.2.0 +========================= + + * Bump version and update deps (Raymond Feng) + + * s/accessToken/access_token in authorization key name (Samuel Reed) + + * Fix resources if the explorer is at a deep path. (Samuel Reed) + + * Fix debug namespace, express version. (Samuel Reed) + + * Remove forgotten TODO. (Samuel Reed) + + * Simplify `accepts` and `returns` hacks. (Samuel Reed) + + * More consise type tests (Samuel Reed) + + * Remove preMiddleware. (Samuel Reed) + + * Remove swagger.test.js license (Samuel Reed) + + * Remove peerDependencies, use express directly. (Samuel Reed) + + * Add url-join so path.join() doesn't break windows (Samuel Reed) + + * Rename translateKeys to translateDataTypeKeys. (Samuel Reed) + + * Refactor route-helper & add tests. (Samuel Reed) + + * LDL to Swagger fixes & extensions. (Samuel Reed) + + * Use express routes instead of modifying remoting. (Samuel Reed) + + * Fix missing strong-remoting devDependency. (Samuel Reed) + + * Restore existing styles. (Samuel Reed) + + * Allow easy setting of accessToken in explorer UI. (Samuel Reed) + + * Refactor key translations between LDL & Swagger. (Samuel Reed) + + * Refactoring swagger 1.2 rework. (Samuel Reed) + + * Make sure body parameter is shown. (Raymond Feng) + + * Some swagger 1.2 migration cleanup. (Samuel Reed) + + * Fix api resource path and type ref to models. (Raymond Feng) + + * Swagger 1.2 compatability. Moved strong-remoting/ext/swagger to this module. (Samuel Reed) + + * Load swagger ui from `swagger-ui` package instead. (Samuel Reed) + + +2014-05-28, Version 1.1.1 +========================= + + * package.json: add support for loopback 2.x (Miroslav Bajtoš) + + * Make sure X-Powered-By header is disabled (Alex Pica) + + * Fix license url (Raymond Feng) + + * Update to dual MIT/StrongLoop license (Raymond Feng) + + +2014-01-14, Version 1.1.0 +========================= + + * Bump up loopback min version to 1.5 (Miroslav Bajtoš) + + * Use `app.get('restApiRoot')` as default basePath (Miroslav Bajtoš) + + * Replace strong-remoting ext/swagger with app.docs (Miroslav Bajtoš) + + +2014-01-13, Version 1.0.2 +========================= + + * Bump version (Raymond Feng) + + * README: mount REST at /api in the sample code (Miroslav Bajtos) + + * Reorder middleware to fix unit-test failures. (Miroslav Bajtos) + + * Fix loading of loopback dependencies. (Miroslav Bajtos) + + +2013-12-04, Version 1.0.1 +========================= + + * First release! diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed649b0..f170f19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ Contributing to `loopback-explorer` is easy. In a few simple steps: * Adhere to code style outlined in the [Google C++ Style Guide][] and [Google Javascript Style Guide][]. - * Sign the [Contributor License Agreement](https://cla.strongloop.com/strongloop/loopback-explorer) + * Sign the [Contributor License Agreement](https://cla.strongloop.com/agreements/strongloop/loopback-explorer) * Submit a pull request through Github. diff --git a/README.md b/README.md index 882e104..9ef49eb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ app.listen(port); ## Advanced Usage -Many aspects of the explorer are configurable. +Many aspects of the explorer are configurable. See [options](#options) for a description of these options: @@ -34,7 +34,10 @@ See [options](#options) for a description of these options: app.use('/explorer', loopback.basicAuth('user', 'password')); app.use('/explorer', explorer(app, { basePath: '/custom-api-root', - swaggerDistRoot: '/swagger', + uiDirs: [ + path.resolve(__dirname, 'public'), + path.resolve(__dirname, 'node_modules', 'swagger-ui') + ] apiInfo: { 'title': 'My API', 'description': 'Explorer example app.' @@ -67,27 +70,27 @@ Options are passed to `explorer(app, options)`. > and thus needs to report its endpoints as `https`, even though incoming traffic is auto-detected > as `http`. -`swaggerDistRoot`: **String** +`uiDirs`: **Array of Strings** -> Sets a path within your application for overriding Swagger UI files. +> Sets a list of paths 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. +> If present, will search `uiDirs` 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 +> 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 +> 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. diff --git a/example/simple.js b/example/simple.js index 3705ca5..70937cd 100644 --- a/example/simple.js +++ b/example/simple.js @@ -3,7 +3,7 @@ var app = loopback(); var explorer = require('../'); var port = 3000; -var Product = loopback.Model.extend('product', { +var Product = loopback.PersistedModel.extend('product', { foo: {type: 'string', required: true}, bar: 'string', aNum: {type: 'number', min: 1, max: 10, required: true, default: 5} @@ -14,6 +14,6 @@ app.model(Product); var apiPath = '/api'; app.use('/explorer', explorer(app, {basePath: apiPath})); app.use(apiPath, loopback.rest()); -console.log('Explorer mounted at localhost:' + port + '/explorer'); +console.log('Explorer mounted at http://localhost:' + port + '/explorer'); app.listen(port); diff --git a/index.js b/index.js index 6c5e003..6048c3a 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,7 @@ var url = require('url'); var path = require('path'); var urlJoin = require('./lib/url-join'); -var _defaults = require('lodash.defaults'); +var _defaults = require('lodash').defaults; var express = require('express'); var swagger = require('./lib/swagger'); var SWAGGER_UI_ROOT = require('swagger-ui').dist; @@ -47,15 +47,29 @@ function explorer(loopbackApplication, options) { }); }); - // 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 + // Allow specifying a static file roots for swagger files. Any files in + // these folders 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.uiDirs) { + if (typeof options.uiDirs === 'string') { + app.use(express.static(options.uiDirs)); + } else if (Array.isArray(options.uiDirs)) { + options.uiDirs.forEach(function(dir) { + app.use(express.static(dir)); + }); + } + } + if (options.swaggerDistRoot) { + console.warn('loopback-explorer: `swaggerDistRoot` is deprecated,' + + ' use `uiDirs` instead'); 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)); diff --git a/lib/class-helper.js b/lib/class-helper.js index e060d98..761b018 100644 --- a/lib/class-helper.js +++ b/lib/class-helper.js @@ -4,6 +4,7 @@ * Module dependencies. */ var modelHelper = require('./model-helper'); +var typeConverter = require('./type-converter'); var urlJoin = require('./url-join'); /** @@ -22,11 +23,16 @@ var classHelper = module.exports = { * @return {Object} API Declaration. */ generateAPIDoc: function(aClass, opts) { + var resourcePath = urlJoin('/', aClass.name); + if(aClass.http && aClass.http.path) { + resourcePath = aClass.http.path; + } + return { apiVersion: opts.version || '1', swaggerVersion: opts.swaggerVersion, basePath: opts.basePath, - resourcePath: urlJoin('/', opts.resourcePath), + resourcePath: urlJoin('/', resourcePath), apis: [], consumes: aClass.http.consumes || opts.consumes, produces: aClass.http.produces || opts.produces, @@ -41,10 +47,12 @@ var classHelper = module.exports = { * @return {Object} API declaration reference. */ generateResourceDocAPIEntry: function(aClass) { - var description = aClass.ctor.settings.description || aClass.ctor.sharedCtor && aClass.ctor.sharedCtor.description; + var description = aClass.ctor.settings.description || + aClass.ctor.sharedCtor && aClass.ctor.sharedCtor.description; + return { path: aClass.http.path, - description: Array.isArray(description) ? description.join('') : description + description: typeConverter.convertText(description) }; } }; diff --git a/lib/model-helper.js b/lib/model-helper.js index 733ab66..a3921fc 100644 --- a/lib/model-helper.js +++ b/lib/model-helper.js @@ -3,9 +3,11 @@ /** * Module dependencies. */ -var _cloneDeep = require('lodash.clonedeep'); +var _cloneDeep = require('lodash').cloneDeep; +var _pick = require('lodash').pick; var translateDataTypeKeys = require('./translate-data-type-keys'); var TYPES_PRIMITIVE = ['array', 'boolean', 'integer', 'number', 'null', 'object', 'string', 'any']; +var typeConverter = require('./type-converter'); /** * Export the modelHelper singleton. @@ -39,8 +41,15 @@ var modelHelper = module.exports = { }; var def = modelClass.definition; - var name = def.name; var out = definitions || {}; + + if (!def) { + // The model does not have any definition, it was most likely + // created as a placeholder for an unknown property type + return out; + } + + var name = def.name; if (out[name]) { // The model is already included return out; @@ -77,13 +86,17 @@ var modelHelper = module.exports = { } // Eke a type out of the constructors we were passed. - prop = modelHelper.LDLPropToSwaggerDataType(prop); - processType(modelClass.app, prop.type, referencedModels); - if (prop.items) { - processType(modelClass.app, prop.items.type, referencedModels); - convertTypeTo$Ref(prop.items); + var swaggerType = modelHelper.LDLPropToSwaggerDataType(prop); + processType(modelClass.app, swaggerType.type, referencedModels); + convertTypeTo$Ref(swaggerType); + if (swaggerType.items) { + processType(modelClass.app, swaggerType.items.type, referencedModels); + convertTypeTo$Ref(swaggerType.items); } + var desc = typeConverter.convertText(prop.description || prop.doc); + if (desc) swaggerType.description = desc; + // Required props sit in a per-model array. if (prop.required || (prop.id && !prop.generated)) { required.push(key); @@ -92,19 +105,17 @@ var modelHelper = module.exports = { // Change mismatched keys. prop = translateDataTypeKeys(prop); - convertTypeTo$Ref(prop); - delete prop.required; delete prop.id; if (prop.description){ - prop.description = Array.isArray(prop.description) ? prop.description.join('') : prop.description; + prop.description = typeConverter.convertText(prop.description); } // Assign this back to the properties object. - properties[key] = prop; + properties[key] = swaggerType; - var propType = def.properties[key].type; + var propType = prop.type; if (typeof propType === 'function' && propType.modelName) { if (referencedModels.indexOf(propType) === -1) { referencedModels.push(propType); @@ -133,12 +144,13 @@ var modelHelper = module.exports = { out[name] = { id: name, additionalProperties: additionalProperties, + description: typeConverter.convertText(def.description), properties: properties, required: required }; if (def.description){ - out[name].description = Array.isArray(def.description) ? def.description.join('') : def.description; + out[name].description = typeConverter.convertText(def.description); } // Generate model definitions for related models @@ -195,9 +207,12 @@ var modelHelper = module.exports = { if (typeof propType === 'function') { // See https://github.com/strongloop/loopback-explorer/issues/32 // The type can be a model class - propType = propType.modelName || propType.name.toLowerCase(); - } else if(Array.isArray(propType)) { - propType = 'array'; + return propType.modelName || propType.name.toLowerCase(); + } else if (Array.isArray(propType)) { + return 'array'; + } else if (typeof propType === 'object') { + // Anonymous objects, they are allowed e.g. in accepts/returns definitions + return 'object'; } return propType; }, @@ -211,38 +226,51 @@ var modelHelper = module.exports = { // 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) { - if (typeof prop === 'string') { - prop = { - type: prop - } - } - var out = _cloneDeep(prop); - out.type = modelHelper.getPropType(out.type); + LDLPropToSwaggerDataType: function LDLPropToSwaggerDataType(ldlType) { + var SWAGGER_DATA_TYPE_FIELDS = [ + 'format', + 'defaultValue', + 'enum', + 'minimum', + 'maximum', + 'uniqueItems', + // loopback-explorer extensions + 'length', + // https://www.npmjs.org/package/swagger-validation + 'pattern' + ]; - if (out.type === 'array') { - var hasItemType = Array.isArray(prop.type) && prop.type.length; - var arrayItem = hasItemType && prop.type[0]; + // Rename LoopBack keys to Swagger keys + ldlType = translateDataTypeKeys(ldlType); + + // Pick only keys supported by Swagger + var swaggerType = _pick(ldlType, SWAGGER_DATA_TYPE_FIELDS); + + swaggerType.type = modelHelper.getPropType(ldlType.type || ldlType); + + if (swaggerType.type === 'array') { + var hasItemType = Array.isArray(ldlType.type) && ldlType.type.length; + var arrayItem = hasItemType && ldlType.type[0]; if (arrayItem) { if(typeof arrayItem === 'object') { - out.items = modelHelper.LDLPropToSwaggerDataType(arrayItem); + swaggerType.items = modelHelper.LDLPropToSwaggerDataType(arrayItem); } else { - out.items = { type: modelHelper.getPropType(arrayItem) }; + swaggerType.items = { type: modelHelper.getPropType(arrayItem) }; } } else { // NOTE: `any` is not a supported type in swagger 1.2 - out.items = { type: 'any' }; + swaggerType.items = { type: 'any' }; } - } else 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 + } else if (swaggerType.type === 'date') { + swaggerType.type = 'string'; + swaggerType.format = 'date'; + } else if (swaggerType.type === 'buffer') { + swaggerType.type = 'string'; + swaggerType.format = 'byte'; + } else if (swaggerType.type === 'number') { + swaggerType.format = 'double'; // Since all JS numbers are doubles } - return out; + return swaggerType; } }; \ No newline at end of file diff --git a/lib/route-helper.js b/lib/route-helper.js index c615d93..32e041d 100644 --- a/lib/route-helper.js +++ b/lib/route-helper.js @@ -5,9 +5,10 @@ */ var debug = require('debug')('loopback:explorer:routeHelpers'); -var _cloneDeep = require('lodash.clonedeep'); -var translateDataTypeKeys = require('./translate-data-type-keys'); +var _cloneDeep = require('lodash').cloneDeep; +var _assign = require('lodash').assign; var modelHelper = require('./model-helper'); +var typeConverter = require('./type-converter'); /** * Export the routeHelper singleton. @@ -61,13 +62,15 @@ var routeHelper = module.exports = { 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; + if (arg.http.source === 'req' || + arg.http.source === 'res' || + arg.http.source === 'context') { + + 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)); @@ -92,19 +95,19 @@ var routeHelper = module.exports = { } } - // Translate LDL keys to Swagger keys. - var returns = routeReturns.map(translateDataTypeKeys); - - // Convert `returns` into a single object for later conversion into an + // Convert `returns` into a single object for later conversion into an // operation object. - if (returns && returns.length > 1) { + if (routeReturns && routeReturns.length > 1) { // TODO ad-hoc model definition in the case of multiple return values. - returns = {model: 'object'}; + routeReturns = { type: 'object' }; } else { - returns = returns[0] || {}; + // 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'. + routeReturns = routeReturns[0] || { type: 'void' }; } - return returns; + return routeReturns; }, /** @@ -181,23 +184,38 @@ var routeHelper = module.exports = { debug('route %j', route); + var responseDoc = modelHelper.LDLPropToSwaggerDataType(returns); + + var responseMessages = [{ + code: route.returns && route.returns.length ? 200 : 204, + message: 'Request was successful' + }]; + + if (route.errors) { + responseMessages.push.apply(responseMessages, route.errors); + } + var apiDoc = { path: routeHelper.convertPathFragments(route.path), - // Create the operation doc. Use `extendWithType` to add the necessary - // `items` and `format` fields. + // Create the operation doc. + // We are using extendWithType to use `type` for the top-level (200) + // response type. We use responseModels for error responses. + // see https://github.com/strongloop/loopback-explorer/issues/75 operations: [routeHelper.extendWithType({ method: routeHelper.convertVerb(route.verb), - // [rfeng] Swagger UI doesn't escape '.' for jQuery selector - nickname: route.method.replace(/\./g, '_'), + // [strml] remove leading model name from op, swagger uses leading + // path as class name so it remains unique between models. + // route.method is always #{className}.#{methodName} + nickname: route.method.replace(/.*?\./, ''), deprecated: route.deprecated, - type: returns.model || returns.type || 'void', - summary: Array.isArray(route.description) ? route.description.join('') : route.description, // TODO(schoon) - Excerpt? - notes: Array.isArray(route.notes) ? route.notes.join('') : route.notes, // TODO(schoon) - `description` metadata? consumes: ['application/json', 'application/xml', 'text/xml'], produces: ['application/json', 'application/javascript', 'application/xml', 'text/javascript', 'text/xml'], parameters: accepts, - responseMessages: responseMessages - })] + responseMessages: responseMessages, + type: returns.model || returns.type || 'void', + summary: typeConverter.convertText(route.description), + notes: typeConverter.convertText(route.notes) + }, returns)] }; return apiDoc; @@ -264,10 +282,10 @@ var routeHelper = module.exports = { minimum: accepts.minimum, maximum: accepts.maximum, allowMultiple: accepts.allowMultiple, - description: Array.isArray(accepts.description) ? accepts.description.join('') : accepts.description + description: typeConverter.convertText(accepts.description) }; - out = routeHelper.extendWithType(out); + out = routeHelper.extendWithType(out, accepts); // HACK: Derive the type from model if(out.name === 'data' && out.type === 'object') { @@ -279,38 +297,41 @@ var routeHelper = module.exports = { }, /** - * Extends an Operation Object or Parameter object with + * 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. + * @param {Object} ldlType LDL type definition + * @return {Object} Extended object. */ - extendWithType: function extendWithType(obj) { + extendWithType: function extendWithType(obj, ldlType) { obj = _cloneDeep(obj); // Format the `type` property using our LDL converter. - var typeDesc = modelHelper - .LDLPropToSwaggerDataType({type: obj.model || obj.type}); + var typeDesc = modelHelper.LDLPropToSwaggerDataType(ldlType); + // The `typeDesc` may have additional attributes, such as // `format` for non-primitive types. Object.keys(typeDesc).forEach(function(key){ obj[key] = typeDesc[key]; }); - //Ensure brief properties are first - if (typeof obj === 'object') { - var keysToSink = ['authorizations', 'consumes', 'notes', 'produces', - 'parameters', 'responseMessages', 'summary']; - var outKeys = Object.keys(obj); - for (var outKeyIdx in outKeys) { - var outKey = outKeys[outKeyIdx]; - if (keysToSink.indexOf(outKey) != -1) { - var outValue = obj[outKey]; - delete obj[outKey]; - obj[outKey] = outValue; - } - } - } + //Ensure brief properties are first + if (typeof obj === 'object') { + var keysToSink = ['authorizations', 'consumes', 'notes', 'produces', + 'parameters', 'responseMessages', 'summary']; + var outKeys = Object.keys(obj); + for (var outKeyIdx in outKeys) { + var outKey = outKeys[outKeyIdx]; + if (keysToSink.indexOf(outKey) != -1) { + var outValue = obj[outKey]; + delete obj[outKey]; + obj[outKey] = outValue; + } + } + } + + _assign(obj, typeDesc); return obj; } diff --git a/lib/swagger.js b/lib/swagger.js index 553cddb..908b155 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -9,10 +9,13 @@ module.exports = Swagger; */ var path = require('path'); var urlJoin = require('./url-join'); -var _defaults = require('lodash.defaults'); +var _defaults = require('lodash').defaults; var classHelper = require('./class-helper'); var routeHelper = require('./route-helper'); -var _cloneDeep = require('lodash.clonedeep'); +var _cloneDeep = require('lodash').cloneDeep; +var modelHelper = require('./model-helper'); +var cors = require('cors'); +var typeConverter = require('./type-converter'); /** * Create a remotable Swagger module for plugging into `RemoteObjects`. @@ -23,13 +26,25 @@ var _cloneDeep = require('lodash.clonedeep'); * @param {Object} opts Options. */ function Swagger(loopbackApplication, swaggerApp, opts) { + if (opts && opts.swaggerVersion) + console.warn('loopback-explorer\'s options.swaggerVersion is deprecated.'); + opts = _defaults(opts || {}, { swaggerVersion: '1.2', basePath: loopbackApplication.get('restApiRoot') || '/api', resourcePath: 'resources', // Default consumes/produces - consumes: ['application/json', 'application/x-www-form-urlencoded', 'application/xml', 'text/xml'], - produces: ['application/json', 'application/javascript', 'application/xml', 'text/javascript', 'text/xml'], + consumes: [ + 'application/json', + 'application/x-www-form-urlencoded', + 'application/xml', 'text/xml' + ], + produces: [ + 'application/json', + 'application/xml', 'text/xml', + // JSONP content types + 'application/javascript', 'text/javascript' + ], version: getVersion() }); @@ -39,6 +54,8 @@ function Swagger(loopbackApplication, swaggerApp, opts) { var routes = adapter.allRoutes(); var classes = remotes.classes(); + setupCors(swaggerApp, remotes); + // These are the docs we will be sending from the /swagger endpoints. var resourceDoc = generateResourceDoc(opts); var apiDocs = {}; @@ -46,15 +63,15 @@ function Swagger(loopbackApplication, swaggerApp, opts) { // 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); - var hasPublic = false; + var hasDocumented = false; var methods = aClass.methods() for (var methodKey in methods) { - hasPublic = methods[methodKey].public; - if (hasPublic) { + hasDocumented = methods[methodKey].documented; + if (hasDocumented) { break; } } - if (hasPublic) { + if (hasDocumented) { resourceDoc.apis.push(classHelper.generateResourceDocAPIEntry(aClass)); } @@ -77,14 +94,59 @@ function Swagger(loopbackApplication, swaggerApp, opts) { return item.name === className; })[0]; - if (route.public) { + if (route.documented) { routeHelper.addRouteToAPIDeclaration(route, classDef, doc); } }); + // Add models referenced from routes (e.g. accepts/returns) + Object.keys(apiDocs).forEach(function(className) { + var classDoc = apiDocs[className]; + classDoc.apis.forEach(function(api) { + api.operations.forEach(function(routeDoc) { + routeDoc.parameters.forEach(function(param) { + var type = param.type; + if (type === 'array' && param.items) + type = param.items.type; + + addTypeToModels(type); + }); + + if (routeDoc.type === 'array') { + addTypeToModels(routeDoc.items.type); + } else { + addTypeToModels(routeDoc.type); + } + + routeDoc.responseMessages.forEach(function(msg) { + addTypeToModels(msg.responseModel); + }); + + function addTypeToModels(name) { + if (!name || name === 'void') return; + + var model = loopbackApplication.models[name]; + if (!model) { + var loopback = loopbackApplication.loopback; + if (!loopback) return; + + if (loopback.findModel) { + model = loopback.findModel(name); // LoopBack 2.x + } else { + model = loopback.getModel(name); // LoopBack 1.x + } + } + if (!model) return; + + modelHelper.generateModelDefinition(model, classDoc.models); + } + }); + }); + }); + /** - * The topmost Swagger resource is a description of all (non-Swagger) - * resources available on the system, and where to find more + * 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, opts); @@ -92,6 +154,13 @@ function Swagger(loopbackApplication, swaggerApp, opts) { loopbackApplication.emit('swaggerResources', resourceDoc); } +function setupCors(swaggerApp, remotes) { + var corsOptions = remotes.options && remotes.options.cors || + { origin: true, credentials: true }; + + swaggerApp.use(cors(corsOptions)); +} + /** * Add a route to this remoting extension. * @param {Application} app Express application. @@ -103,21 +172,28 @@ function addRoute(app, uri, doc, opts) { var hasBasePath = Object.keys(doc).indexOf('basePath') !== -1; var initialPath = doc.basePath || ''; + // Remove the trailing slash, see + // https://github.com/strongloop/loopback-explorer/issues/48 + if (initialPath[initialPath.length-1] === '/') + initialPath = initialPath.slice(0, -1); + 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 + // 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. + // + // The simplest way around this is to reflect the value of the `Host` and/or + // `X-Forwarded-Host` HTTP headers 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['x-forwarded-host'] || headers['X-Forwarded-Host'] || headers.Host || headers.host; - var protocol = headers['x-forwarded-proto'] || headers['X-Forwarded-Proto'] || opts.protocol || req.protocol - doc.basePath = protocol + '://' + host + initialPath; + // NOTE header names (keys) are always all-lowercase + var proto = headers['x-forwarded-proto'] || opts.protocol || req.protocol; + var host = headers['x-forwarded-host'] || headers.host; + doc.basePath = proto + '://' + host + initialPath; } res.status(200).send(doc); }); @@ -133,7 +209,7 @@ function generateResourceDoc(opts) { var apiInfo = _cloneDeep(opts.apiInfo); for (var propertyName in apiInfo) { var property = apiInfo[propertyName]; - apiInfo[propertyName] = Array.isArray(property) ? property.join('') : property; + apiInfo[propertyName] = typeConverter.convertText(property); } return { @@ -159,7 +235,7 @@ function getVersion() { try { version = require(path.join(process.cwd(), 'package.json')).version; } catch(e) { - version = '1'; + version = '1.0.0'; } return version; } diff --git a/lib/translate-data-type-keys.js b/lib/translate-data-type-keys.js index a47c85d..d2ca63a 100644 --- a/lib/translate-data-type-keys.js +++ b/lib/translate-data-type-keys.js @@ -4,12 +4,11 @@ * Module dependencies. */ -var _cloneDeep = require('lodash.clonedeep'); +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' diff --git a/lib/type-converter.js b/lib/type-converter.js new file mode 100644 index 0000000..c7eb686 --- /dev/null +++ b/lib/type-converter.js @@ -0,0 +1,14 @@ +var typeConverter = module.exports = { + + /** + * Convert a text value that can be expressed either as a string or + * as an array of strings. + * @param {string|Array} value + * @returns {string} + */ + convertText: function(value) { + if (Array.isArray(value)) + return value.join('\n'); + return value; + } +}; diff --git a/package.json b/package.json index 707673a..bf457ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback-explorer", - "version": "1.2.13", + "version": "1.7.1", "description": "Browse and test your LoopBack app's APIs", "main": "index.js", "scripts": { @@ -21,20 +21,20 @@ "url": "https://github.com/strongloop/loopback-explorer/issues" }, "devDependencies": { - "loopback": "git+https://github.com/shelbys/loopback.git", - "mocha": "~1.20.1", - "supertest": "~0.13.0", - "chai": "~1.9.1" + "loopback": "git+https://github.com/shelbys/loopback.git#validate_param_enum", + "mocha": "^2.2.5", + "supertest": "~1.0.1", + "chai": "^2.3.0" }, "license": { "name": "Dual MIT/StrongLoop", "url": "https://github.com/strongloop/loopback-explorer/blob/master/LICENSE" }, "dependencies": { - "swagger-ui": "git+https://github.com/shelbys/swagger-ui.git", + "cors": "^2.4.2", "debug": "~1.0.3", - "lodash.clonedeep": "^2.4.1", - "lodash.defaults": "^2.4.1", - "express": "3.x" + "express": "3.x", + "lodash": "^2.4.1", + "swagger-ui": "git+https://github.com/shelbys/swagger-ui.git" } } diff --git a/public/css/loopbackStyles.css b/public/css/loopbackStyles.css index 53a6283..babf1a7 100644 --- a/public/css/loopbackStyles.css +++ b/public/css/loopbackStyles.css @@ -34,6 +34,7 @@ color: #080; } +<<<<<<< HEAD .contentWell { padding-left: 30px; padding-right: 30px; @@ -42,4 +43,14 @@ /* FIXME: Separate the overrides from the rest of the styles, rather than override screen.css entirely. */ +======= +/* Improve spacing when the browser window is small */ +#message-bar, #swagger-ui-container { + padding-left: 30px; + padding-right: 30px; +} +>>>>>>> upstream/master +#api_selector { + padding: 0px 20px; +} diff --git a/public/index.html b/public/index.html index cae33f7..5696c68 100644 --- a/public/index.html +++ b/public/index.html @@ -1,30 +1,29 @@ - StrongLoop API Explorer - - - - - - - - - - - - - - - - - + + StrongLoop API Explorer + + + + + + + + + + + + + + + - - + + - - + + diff --git a/public/lib/loadSwaggerUI.js b/public/lib/loadSwaggerUI.js index c89917b..8cea670 100644 --- a/public/lib/loadSwaggerUI.js +++ b/public/lib/loadSwaggerUI.js @@ -3,9 +3,10 @@ // Refactoring of inline script from index.html. /*global SwaggerUi, log, ApiKeyAuthorization, hljs, window, $ */ $(function() { + var lsKey = 'swagger_accessToken'; $.getJSON('config.json', function(config) { - log(config); - loadSwaggerUi(config); + log(config); + loadSwaggerUi(config); }); var accessToken; @@ -35,6 +36,14 @@ $(function() { $('#api_selector').submit(setAccessToken); $('#input_accessToken').keyup(onInputChange); + // Recover accessToken from localStorage if present. + if (window.localStorage) { + var key = window.localStorage.getItem(lsKey); + if (key) { + $('#input_accessToken').val(key).submit(); + } + } + window.swaggerUi.load(); } @@ -49,6 +58,22 @@ $(function() { accessToken = key; $('.accessTokenDisplay').text('Token Set.').addClass('set'); $('.accessTokenDisplay').attr('data-tooltip', 'Current Token: ' + key); + + // Save this token to localStorage if we can to make it persist on refresh. + if (window.localStorage) { + window.localStorage.setItem(lsKey, key); + } + } + // If submitted with an empty token, remove the current token. Can be + // useful to intentionally remove authorization. + else { + log('removed accessToken.'); + $('.accessTokenDisplay').text('Token Not Set.').removeClass('set'); + $('.accessTokenDisplay').removeAttr('data-tooltip'); + window.authorizations.remove('key'); + if (window.localStorage) { + window.localStorage.removeItem(lsKey); + } } } diff --git a/test/class-helper.test.js b/test/class-helper.test.js index 63e88cb..80cbe49 100644 --- a/test/class-helper.test.js +++ b/test/class-helper.test.js @@ -1,11 +1,31 @@ -/** - * Created by ytang on 4/7/15. - */ +'use strict'; + var classHelper = require('../lib/class-helper'); -var loopback = require('loopback'); var expect = require('chai').expect; +var _defaults = require('lodash').defaults; +var loopback = require('loopback'); describe('class-helper', function() { + it('joins array descriptions', function() { + var doc = generateResourceDocAPIEntry({ + ctor: { settings: { description: [ 'line1', 'line2' ] } } + }); + + expect(doc.description).to.equal('line1\nline2'); + }); + + it('sets resourcePath from aClass.http.path', function() { + var doc = generateAPIDoc({}, 'otherPath'); + + expect(doc.resourcePath).to.equal('/otherPath'); + }); + + it('sets resourcePath from aClass.name', function() { + var doc = generateAPIDoc({}); + + expect(doc.resourcePath).to.equal('/test'); + }); + describe('#generateResourceDocAPIEntry', function() { describe('when ctor.settings.description is an array of string', function() { it('should return description as a string', function() { @@ -21,7 +41,7 @@ describe('class-helper', function() { }; var result = classHelper.generateResourceDocAPIEntry(aClass); - expect(result.description).to.eql('123'); + expect(result.description).to.eql("1\n2\n3"); }); }); @@ -40,8 +60,24 @@ describe('class-helper', function() { }; var result = classHelper.generateResourceDocAPIEntry(aClass); - expect(result.description).to.eql('123'); + expect(result.description).to.eql("1\n2\n3"); }); }); }); -}); \ No newline at end of file +}); + +// Easy wrapper around createRoute +function generateResourceDocAPIEntry(def) { + return classHelper.generateResourceDocAPIEntry(_defaults(def, { + http: { path: '/test' }, + ctor: { settings: { } } + })); +} + +function generateAPIDoc(def, httpPath) { + return classHelper.generateAPIDoc(_defaults(def, { + http: { path: httpPath || null }, + name: 'test', + ctor: { settings: { } } + }), {resourcePath: 'resources'}); +} diff --git a/test/explorer.test.js b/test/explorer.test.js index e53c4d9..431fd3f 100644 --- a/test/explorer.test.js +++ b/test/explorer.test.js @@ -2,7 +2,10 @@ var loopback = require('loopback'); var explorer = require('../'); var request = require('supertest'); var assert = require('assert'); +var path = require('path'); var expect = require('chai').expect; +var urlJoin = require('../lib/url-join'); +var os = require('os'); describe('explorer', function() { @@ -24,7 +27,7 @@ describe('explorer', function() { .end(function(err, res) { if (err) throw err; - assert(!!~res.text.indexOf('StrongLoop API Explorer'), + assert(!!~res.text.indexOf('StrongLoop API Explorer'), 'text does not contain expected string'); done(); }); @@ -77,6 +80,83 @@ describe('explorer', function() { done(); }); }); + + it('removes trailing slash from baseUrl', function(done) { + // SwaggerUI builds resource URL by concatenating basePath + resourcePath + // Since the resource paths are always startign with a slash, + // if the basePath ends with a slash too, an incorrect URL is produced + var app = loopback(); + app.set('restApiRoot', '/'); + configureRestApiAndExplorer(app); + + request(app) + .get('/explorer/resources/products') + .expect(200) + .end(function(err, res) { + if (err) return done(err); + var baseUrl = res.body.basePath; + var apiPath = res.body.apis[0].path; + expect(baseUrl + apiPath).to.match(/http:\/\/[^\/]+\/products/); + done(); + }); + }); + }); + + describe('with custom front-end files', function() { + var app; + beforeEach(function setupExplorerWithUiDirs() { + app = loopback(); + app.use('/explorer', explorer(app, { + uiDirs: [ path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui') ] + })); + }); + + it('overrides swagger-ui files', function(done) { + request(app).get('/explorer/swagger-ui.js') + .expect(200) + // expect the content of `dummy-swagger-ui/swagger-ui.js` + .expect('/* custom swagger-ui file */' + os.EOL) + .end(done); + }); + + it('overrides strongloop overrides', function(done) { + request(app).get('/explorer/') + .expect(200) + // expect the content of `dummy-swagger-ui/index.html` + .expect('custom index.html' + os.EOL) + .end(done); + }); + }); + + describe('when specifying custom static file root directories', function() { + var app; + beforeEach(function() { + app = loopback(); + }); + + it('should allow `uiDirs` to be defined as an Array', function(done) { + app.use('/explorer', explorer(app, { + uiDirs: [ path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui') ] + })); + + request(app).get('/explorer/') + .expect(200) + // expect the content of `dummy-swagger-ui/index.html` + .expect('custom index.html' + os.EOL) + .end(done); + }); + + it('should allow `uiDirs` to be defined as an String', function(done) { + app.use('/explorer', explorer(app, { + uiDirs: path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui') + })); + + request(app).get('/explorer/') + .expect(200) + // expect the content of `dummy-swagger-ui/index.html` + .expect('custom index.html' + os.EOL) + .end(done); + }); }); function givenLoopBackAppWithExplorer(explorerBase) { @@ -88,7 +168,7 @@ describe('explorer', function() { } function configureRestApiAndExplorer(app, explorerBase) { - var Product = loopback.Model.extend('product'); + var Product = loopback.PersistedModel.extend('product'); Product.attachTo(loopback.memory()); app.model(Product); diff --git a/test/fixtures/dummy-swagger-ui/index.html b/test/fixtures/dummy-swagger-ui/index.html new file mode 100644 index 0000000..f6c3a1f --- /dev/null +++ b/test/fixtures/dummy-swagger-ui/index.html @@ -0,0 +1 @@ +custom index.html diff --git a/test/fixtures/dummy-swagger-ui/swagger-ui.js b/test/fixtures/dummy-swagger-ui/swagger-ui.js new file mode 100644 index 0000000..55f593f --- /dev/null +++ b/test/fixtures/dummy-swagger-ui/swagger-ui.js @@ -0,0 +1 @@ +/* custom swagger-ui file */ diff --git a/test/model-helper.test.js b/test/model-helper.test.js index 4b6b57a..7de1b1f 100644 --- a/test/model-helper.test.js +++ b/test/model-helper.test.js @@ -1,6 +1,7 @@ 'use strict'; var modelHelper = require('../lib/model-helper'); +var _defaults = require('lodash').defaults; var loopback = require('loopback'); var expect = require('chai').expect; @@ -122,6 +123,27 @@ describe('model-helper', function() { }); }); + + it('converts model property field `doc`', function() { + var def = buildSwaggerModels({ + name: { type: String, doc: 'a-description' } + }); + var nameProp = def.properties.name; + expect(nameProp).to.have.property('description', 'a-description'); + }); + + it('converts model property field `description`', function() { + var def = buildSwaggerModels({ + name: { type: String, description: 'a-description' } + }); + var nameProp = def.properties.name; + expect(nameProp).to.have.property('description', 'a-description'); + }); + + it('converts model field `description`', function() { + var def = buildSwaggerModels({}, { description: 'a-description' }); + expect(def).to.have.property('description', 'a-description'); + }); }); describe('related models', function() { @@ -138,7 +160,7 @@ describe('model-helper', function() { var Model1 = loopback.createModel('Model1', { str: String, // 'string' address: Model2 - }); + }, { models: { Model2: Model2 } }); var defs = modelHelper.generateModelDefinition(Model1, {}); expect(defs).has.property('Model1'); expect(defs).has.property('Model2'); @@ -147,7 +169,7 @@ describe('model-helper', function() { it('should include used models', function() { var Model4 = loopback.createModel('Model4', {street: String}); var Model3 = loopback.createModel('Model3', { - str: String, // 'string' + str: String // 'string' }, {models: {model4: 'Model4'}}); var defs = modelHelper.generateModelDefinition(Model3, {}); expect(defs).has.property('Model3'); @@ -159,7 +181,7 @@ describe('model-helper', function() { var Model5 = loopback.createModel('Model5', { str: String, // 'string' addresses: [Model6] - }); + }, { models: { Model6: Model6 } }); var defs = modelHelper.generateModelDefinition(Model5, {}); expect(defs).has.property('Model5'); expect(defs).has.property('Model6'); @@ -176,6 +198,18 @@ describe('model-helper', function() { expect(Object.keys(defs)).has.property('length', 1); }); + // https://github.com/strongloop/loopback-explorer/issues/71 + it('should skip unknown types', function() { + var Model8 = loopback.createModel('Model8', { + patient: { + model: 'physician', + type: 'hasMany', + through: 'appointment' + } + }); + var defs = modelHelper.generateModelDefinition(Model8, {}); + expect(Object.keys(defs)).to.not.contain('hasMany'); + }); }); describe('hidden properties', function() { @@ -193,53 +227,66 @@ describe('model-helper', function() { }); }); - describe('#generateModelDefinition', function(){ - it('should convert top level array description to string', function(){ + describe('#generateModelDefinition', function() { + it('should convert top level array description to string', function () { var model = {}; model.definition = { name: 'test', - description: ['1','2','3'], + description: ['1', '2', '3'], properties: {} }; var models = {}; modelHelper.generateModelDefinition(model, models); - expect(models.test.description).to.equal('123'); + expect(models.test.description).to.equal("1\n2\n3"); }); - it('should convert property level array description to string', function(){ + it('should convert property level array description to string', function () { var model = {}; model.definition = { name: 'test', properties: { prop1: { type: 'string', - description: ['1','2','3'] + description: ['1', '2', '3'] } } }; var models = {}; modelHelper.generateModelDefinition(model, models); - expect(models.test.properties.prop1.description).to.equal('123'); + expect(models.test.properties.prop1.description).to.equal("1\n2\n3"); + }); + }); + + describe('getPropType', function() { + it('converts anonymous object types', function() { + var type = modelHelper.getPropType({ name: 'string', value: 'string' }); + expect(type).to.eql('object'); }); }); }); // Simulates the format of a remoting class. -function buildSwaggerModels(model) { - var aClass = createModelCtor(model); +function buildSwaggerModels(modelProperties, modelOptions) { + var aClass = createModelCtor(modelProperties, modelOptions); return modelHelper.generateModelDefinition(aClass.ctor, {}).testModel; } -function createModelCtor(model) { - Object.keys(model).forEach(function(name) { - model[name] = {type: model[name]}; +function createModelCtor(properties, modelOptions) { + Object.keys(properties).forEach(function(name) { + var type = properties[name]; + if (typeof type !== 'object' || Array.isArray(type)) + properties[name] = { type: type }; }); + + var definition = { + name: 'testModel', + properties: properties + }; + _defaults(definition, modelOptions); + var aClass = { ctor: { - definition: { - name: 'testModel', - properties: model - } + definition: definition } }; return aClass; diff --git a/test/route-helper.test.js b/test/route-helper.test.js index addd737..d2decc3 100644 --- a/test/route-helper.test.js +++ b/test/route-helper.test.js @@ -2,7 +2,7 @@ var routeHelper = require('../lib/route-helper'); var expect = require('chai').expect; -var _defaults = require('lodash.defaults'); +var _defaults = require('lodash').defaults; describe('route-helper', function() { it('returns "object" when a route has multiple return values', function() { @@ -13,7 +13,8 @@ describe('route-helper', function() { { arg: 'avg', type: 'number' } ] }); - expect(doc.operations[0].responseMessages[0].responseModel).to.equal('object'); + expect(doc.operations[0].type).to.equal('object'); + expect(getResponseType(doc.operations[0])).to.equal(undefined); }); it('converts path params when they exist in the route name', function() { @@ -60,55 +61,194 @@ describe('route-helper', function() { ] }); var opDoc = doc.operations[0]; - expect(opDoc.responseMessages[0].responseModel).to.equal('[customType]'); + expect(getResponseType(opDoc)).to.equal(undefined); + + // NOTE(bajtos) this would be the case if there was a single response type + expect(opDoc.type).to.equal('array'); + expect(opDoc.items).to.eql({type: 'customType'}); }); it('correctly converts return types (format)', function() { var doc = createAPIDoc({ - returns: [ - {arg: 'data', type: 'buffer'} - ] + returns: [ + {arg: 'data', type: 'buffer'} + ] + }); + var opDoc = doc.operations[0]; + expect(opDoc.type).to.equal('string'); + expect(opDoc.format).to.equal('byte'); + }); + + it('includes `notes` metadata', function() { + var doc = createAPIDoc({ + notes: 'some notes' }); - var opDoc = doc.operations[0]; - expect(opDoc.responseMessages[0].responseModel).to.equal('string'); + expect(doc.operations[0].notes).to.equal('some notes'); }); describe('#acceptToParameter', function(){ it('should return function that converts accepts.description from array of string to string', function(){ var f = routeHelper.acceptToParameter({verb: 'get', path: 'path'}); var result = f({description: ['1','2','3']}); - expect(result.description).to.eql('123'); + expect(result.description).to.eql("1\n2\n3"); }); }); - describe('#routeToAPIDoc', function(){ - it('should convert route.description from array fo string to string', function(){ + describe('#routeToAPIDoc', function() { + it('should convert route.description from array fo string to string', function () { var result = routeHelper.routeToAPIDoc({ method: 'someMethod', verb: 'get', path: 'path', - description:['1','2','3'] + description: ['1', '2', '3'] }); - expect(result.operations[0].summary).to.eql('123'); + expect(result.operations[0].summary).to.eql("1\n2\n3"); }); - it('should convert route.notes from array fo string to string', function(){ + it('should convert route.notes from array fo string to string', function () { var result = routeHelper.routeToAPIDoc({ method: 'someMethod', verb: 'get', path: 'path', - notes:['1','2','3'] + notes: ['1', '2', '3'] }); - expect(result.operations[0].notes).to.eql('123'); + expect(result.operations[0].notes).to.eql("1\n2\n3"); }); }); + + it('includes `deprecated` metadata', function() { + var doc = createAPIDoc({ + deprecated: 'true' + }); + expect(doc.operations[0].deprecated).to.equal('true'); + }); + + it('joins array description/summary', function() { + var doc = createAPIDoc({ + description: [ 'line1', 'line2' ] + }); + expect(doc.operations[0].summary).to.equal('line1\nline2'); + }); + + it('joins array notes', function() { + var doc = createAPIDoc({ + notes: [ 'line1', 'line2' ] + }); + expect(doc.operations[0].notes).to.equal('line1\nline2'); + }); + + it('joins array description/summary of an input arg', function() { + var doc = createAPIDoc({ + accepts: [{ name: 'arg', description: [ 'line1', 'line2' ] }] + }); + expect(doc.operations[0].parameters[0].description).to.equal('line1\nline2'); + }); + + it('correctly does not include context params', function() { + var doc = createAPIDoc({ + accepts: [ + {arg: 'ctx', http: {source: 'context'}} + ], + path: '/test' + }); + var params = doc.operations[0].parameters; + expect(params.length).to.equal(0); + }); + + it('correctly does not include request params', function() { + var doc = createAPIDoc({ + accepts: [ + {arg: 'req', http: {source: 'req'}} + ], + path: '/test' + }); + var params = doc.operations[0].parameters; + expect(params.length).to.equal(0); + }); + + it('correctly does not include response params', function() { + var doc = createAPIDoc({ + accepts: [ + {arg: 'res', http: {source: 'res'}} + ], + path: '/test' + }); + var params = doc.operations[0].parameters; + expect(params.length).to.equal(0); + }); + + it('preserves `enum` accepts arg metadata', function() { + var doc = createAPIDoc({ + accepts: [{ name: 'arg', type: 'number', enum: [1,2,3] }] + }); + expect(doc.operations[0].parameters[0]) + .to.have.property('enum').eql([1,2,3]); + }); + + it('includes the default response message with code 200', function() { + var doc = createAPIDoc({ + returns: [{ name: 'result', type: 'object', root: true }] + }); + expect(doc.operations[0].type).to.eql('object'); + expect(doc.operations[0].responseMessages).to.eql([ + { + code: 200, + message: 'Request was successful' + } + ]); + }); + + it('uses the response code 204 when `returns` is empty', function() { + var doc = createAPIDoc({ + returns: [] + }); + expect(doc.operations[0].type).to.eql('void'); + expect(doc.operations[0].responseMessages).to.eql([ + { + code: 204, + message: 'Request was successful' + } + ]); + }); + + it('includes custom error response in `responseMessages`', function() { + var doc = createAPIDoc({ + errors: [{ + code: 422, + message: 'Validation failed', + responseModel: 'ValidationError' + }] + }); + expect(doc.operations[0].responseMessages[1]).to.eql({ + code: 422, + message: 'Validation failed', + responseModel: 'ValidationError' + }); + }); + + it('route nickname does not include model name.', function() { + var doc = createAPIDoc(); + expect(doc.operations[0].nickname).to.equal('get'); + }); + + it('route nickname with a period is shorted correctly', function() { + // Method is built by remoting to always be #{className}.#{methodName} + var doc = createAPIDoc({ + method: 'test.get.me' + }); + expect(doc.operations[0].nickname).to.eql('get.me'); + }); }); // Easy wrapper around createRoute function createAPIDoc(def) { - return routeHelper.routeToAPIDoc(_defaults(def, { + return routeHelper.routeToAPIDoc(_defaults(def || {}, { path: '/test', verb: 'GET', method: 'test.get' })); } + +function getResponseType(operationDoc) { + return operationDoc.responseMessages[0].responseModel; +} diff --git a/test/swagger.test.js b/test/swagger.test.js index 1cd0a5b..a0e2935 100644 --- a/test/swagger.test.js +++ b/test/swagger.test.js @@ -13,7 +13,7 @@ describe('swagger definition', function() { describe('basePath', function() { // No basepath on resource doc in 1.2 it('no longer exists on resource doc', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); var getReq = getSwaggerResources(app); getReq.end(function(err, res) { @@ -24,7 +24,7 @@ describe('swagger definition', function() { }); it('is "http://{host}/api" by default', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -35,7 +35,7 @@ describe('swagger definition', function() { }); it('is "http://{host}/{basePath}" when basePath is a path', function(done){ - var app = mountSwagger({ basePath: '/api-root'}); + var app = givenAppWithSwagger({ basePath: '/api-root'}); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -47,7 +47,7 @@ describe('swagger definition', function() { }); it('infers API basePath from app', function(done){ - var app = mountSwagger({}, {apiRoot: '/custom-api-root'}); + var app = givenAppWithSwagger({}, {apiRoot: '/custom-api-root'}); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -60,7 +60,7 @@ describe('swagger definition', function() { it('is reachable when explorer mounting location is changed', function(done){ var explorerRoot = '/erforscher'; - var app = mountSwagger({}, {explorerRoot: explorerRoot}); + var app = givenAppWithSwagger({}, {explorerRoot: explorerRoot}); var getReq = getSwaggerResources(app, explorerRoot, 'products'); getReq.end(function(err, res) { @@ -71,7 +71,7 @@ describe('swagger definition', function() { }); it('respects a hardcoded protocol (behind SSL terminator)', function(done){ - var app = mountSwagger({protocol: 'https'}); + var app = givenAppWithSwagger({protocol: 'https'}); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -81,11 +81,35 @@ describe('swagger definition', function() { done(); }); }); + + it('respects X-Forwarded-Host header (behind a proxy)', function(done) { + var app = givenAppWithSwagger(); + getAPIDeclaration(app, 'products') + .set('X-Forwarded-Host', 'example.com') + .end(function(err, res) { + if (err) return done(err); + var baseUrl = url.parse(res.body.basePath); + expect(baseUrl.hostname).to.equal('example.com'); + done(); + }); + }); + + it('respects X-Forwarded-Proto header (behind a proxy)', function(done) { + var app = givenAppWithSwagger(); + getAPIDeclaration(app, 'products') + .set('X-Forwarded-Proto', 'https') + .end(function(err, res) { + if (err) return done(err); + var baseUrl = url.parse(res.body.basePath); + expect(baseUrl.protocol).to.equal('https:'); + done(); + }); + }); }); describe('Model definition attributes', function() { it('Properly defines basic attributes', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -104,39 +128,257 @@ describe('swagger definition', function() { done(); }); }); + + it('includes `consumes`', function(done) { + var app = givenAppWithSwagger(); + getAPIDeclaration(app, 'products').end(function(err, res) { + if (err) return done(err); + expect(res.body.consumes).to.have.members([ + 'application/json', + 'application/x-www-form-urlencoded', + 'application/xml', 'text/xml' + ]); + done(); + }); + }); + + it('includes `produces`', function(done) { + var app = givenAppWithSwagger(); + getAPIDeclaration(app, 'products').end(function(err, res) { + if (err) return done(err); + expect(res.body.produces).to.have.members([ + 'application/json', + 'application/xml', 'text/xml', + // JSONP content types + 'application/javascript', 'text/javascript' + ]); + done(); + }); + }); + + it('includes models from `accepts` args', function(done) { + var app = createLoopbackAppWithModel(); + givenPrivateAppModel(app, 'Image'); + givenSharedMethod(app.models.Product, 'setImage', { + accepts: { name: 'image', type: 'Image' } + }); + mountExplorer(app); + + getAPIDeclaration(app, 'products').end(function(err, res) { + expect(Object.keys(res.body.models)).to.include('Image'); + done(); + }); + }); + + it('includes models from `returns` args', function(done) { + var app = createLoopbackAppWithModel(); + givenPrivateAppModel(app, 'Image'); + givenSharedMethod(app.models.Product, 'getImage', { + returns: { name: 'image', type: 'Image' } + }); + mountExplorer(app); + + getAPIDeclaration(app, 'products').end(function(err, res) { + expect(Object.keys(res.body.models)).to.include('Image'); + done(); + }); + }); + + it('includes `accepts` models not attached to the app', function(done) { + var app = createLoopbackAppWithModel(); + loopback.createModel('Image'); + givenSharedMethod(app.models.Product, 'setImage', { + accepts: { name: 'image', type: 'Image' } + }); + mountExplorer(app); + + getAPIDeclaration(app, 'products').end(function(err, res) { + expect(Object.keys(res.body.models)).to.include('Image'); + done(); + }); + }); + + it('includes `responseMessages` models', function(done) { + var app = createLoopbackAppWithModel(); + loopback.createModel('ValidationError'); + givenSharedMethod(app.models.Product, 'setImage', { + errors: [{ + code: '422', + message: 'Validation failed', + responseModel: 'ValidationError' + }] + }); + + expectProductDocIncludesModels(app, 'ValidationError', done); + }); + + it('includes nested model references in properties', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + app.models.Product.defineProperty('location', { type: 'Warehouse' }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested array model references in properties', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + app.models.Product.defineProperty('location', { type: ['Warehouse'] }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested model references in modelTo relation', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + app.models.Product.belongsTo(app.models.Warehouse); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested model references in modelTo relation', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + givenPrivateAppModel(app, 'ProductLocations'); + + app.models.Product.hasMany(app.models.Warehouse, + { through: app.models.ProductLocations }); + + expectProductDocIncludesModels( + app, + ['Address', 'Warehouse', 'ProductLocations'], + done); + }); + + it('includes nested model references in accept args', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + accepts: { arg: 'w', type: 'Warehouse' } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested array model references in accept args', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + accepts: { arg: 'w', type: [ 'Warehouse' ] } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested model references in return args', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + returns: { arg: 'w', type: 'Warehouse', root: true } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested array model references in return args', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + returns: { arg: 'w', type: ['Warehouse'], root: true } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested model references in error responses', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + errors: { + code: '222', + message: 'Warehouse', + responseModel: 'Warehouse' + } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + }); + + describe('Cross-origin resource sharing', function() { + it('allows cross-origin requests by default', function(done) { + var app = givenAppWithSwagger(); + request(app) + .options('/explorer/resources') + .set('Origin', 'http://example.com/') + .expect('Access-Control-Allow-Origin', /^http:\/\/example.com\/|\*/) + .expect('Access-Control-Allow-Methods', /\bGET\b/) + .end(done); + }); + + it('can be disabled by configuration', function(done) { + var app = givenAppWithSwagger({}, { remoting: { cors: { origin: false } } }); + request(app) + .options('/explorer/resources') + .end(function(err, res) { + if (err) return done(err); + var allowOrigin = res.get('Access-Control-Allow-Origin'); + expect(allowOrigin, 'Access-Control-Allow-Origin') + .to.equal(undefined); + done(); + }); + }); }); function getSwaggerResources(app, restPath, classPath) { return request(app) .get(urlJoin(restPath || '/explorer', '/resources', classPath || '')) .set('Accept', 'application/json') - .expect('Content-Type', /json/) - .expect(200); + .expect(200) + .expect('Content-Type', /json/); } function getAPIDeclaration(app, className) { return getSwaggerResources(app, '', urlJoin('/', className)); } - function mountSwagger(options, addlOptions) { - addlOptions = addlOptions || {}; - var app = createLoopbackAppWithModel(addlOptions.apiRoot); + function givenAppWithSwagger(swaggerOptions, appConfig) { + appConfig = appConfig || {}; + var app = createLoopbackAppWithModel(appConfig.apiRoot); + + if (appConfig.remoting) app.set('remoting', appConfig.remoting); + if (appConfig.explorerRoot) app.set('explorerRoot', appConfig.explorerRoot); + + mountExplorer(app, swaggerOptions); + return app; + } + + function mountExplorer(app, options) { var swaggerApp = express(); swagger(app, swaggerApp, options); - app.use(addlOptions.explorerRoot || '/explorer', swaggerApp); + app.use(app.get('explorerRoot') || '/explorer', swaggerApp); return app; } function createLoopbackAppWithModel(apiRoot) { var app = loopback(); + app.dataSource('db', { connector: 'memory' }); + 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.model(Product, { dataSource: 'db'}); // Simulate a restApiRoot set in config app.set('restApiRoot', apiRoot || '/api'); @@ -144,4 +386,33 @@ describe('swagger definition', function() { return app; } + + function givenSharedMethod(model, name, metadata) { + model[name] = function(){}; + loopback.remoteMethod(model[name], metadata); + } + + function givenPrivateAppModel(app, name, properties) { + var model = loopback.createModel(name, properties); + app.model(model, { dataSource: 'db', documented: false} ); + } + + function givenWarehouseWithAddressModels(app) { + givenPrivateAppModel(app, 'Address'); + givenPrivateAppModel(app, 'Warehouse', { + shippingAddress: { type: 'Address' } + }); + } + + function expectProductDocIncludesModels(app, modelNames, done) { + if (!Array.isArray(modelNames)) modelNames = [modelNames]; + + mountExplorer(app); + + getAPIDeclaration(app, 'products').end(function(err, res) { + if (err) return done(err); + expect(Object.keys(res.body.models)).to.include.members(modelNames); + done(); + }); + } });