From 0b1781154616588d46af2dc3ce413a4d6c509d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 13 Aug 2015 17:20:11 +0200 Subject: [PATCH] Generate Swagger Spec 2.0 documentation Notable breaking changes: - The swagger output is a single object (JSON response) served at /explorer/swagger.json - Methods with a single return arg without "root:true" flag are expected to produce an object response with a single property now, i.e. `{ data: arg }`. In v1.x, we were treating such arg as if "root:true" was specified. The new behaviour matches the actual implementation in strong-remoting. - The property constraint "length" is translated to "maxLength" now. - `operationId` includes model name now, because ids must be unique - X-Forwarded-* headers are no longer processed, Swagger Spec 2.0 has a way how to specify "use the scheme + host where the doc is served" - opts.omitProtocolInBaseUrl was removed for the same reasons as X-Forwarded-* headers - The deprecated opts.swaggerDistRoot was removed. --- index.js | 10 +- lib/class-helper.js | 58 ----- lib/model-helper.js | 285 +++++------------------ lib/route-helper.js | 333 ++++++++++----------------- lib/schema-builder.js | 170 ++++++++++++++ lib/swagger.js | 261 ++++++++------------- lib/tag-builder.js | 18 ++ lib/translate-data-type-keys.js | 37 --- lib/type-converter.js | 2 + lib/type-registry.js | 43 ++++ test/class-helper.test.js | 82 ------- test/explorer.test.js | 19 +- test/model-helper.test.js | 231 ++++--------------- test/route-helper.test.js | 182 ++++++++------- test/schema-builder.test.js | 100 ++++++++ test/swagger.test.js | 394 +++++++++++++++----------------- test/tag-builder.test.js | 23 ++ 17 files changed, 984 insertions(+), 1264 deletions(-) delete mode 100644 lib/class-helper.js create mode 100644 lib/schema-builder.js create mode 100644 lib/tag-builder.js delete mode 100644 lib/translate-data-type-keys.js create mode 100644 lib/type-registry.js delete mode 100644 test/class-helper.test.js create mode 100644 test/schema-builder.test.js create mode 100644 test/tag-builder.test.js diff --git a/index.js b/index.js index 1fa4d36..b44c1e1 100644 --- a/index.js +++ b/index.js @@ -35,13 +35,13 @@ function routes(loopbackApplication, options) { } options = _defaults({}, options, { - resourcePath: 'resources', + resourcePath: 'swagger.json', apiInfo: loopbackApplication.get('apiInfo') || {} }); var router = new loopback.Router(); - swagger(loopbackApplication, router, options); + swagger.mountSwagger(loopbackApplication, router, options); // config.json is loaded by swagger-ui. The server should respond // with the relative URI of the resource doc. @@ -72,12 +72,6 @@ function routes(loopbackApplication, options) { } } - if (options.swaggerDistRoot) { - console.warn('loopback-explorer: `swaggerDistRoot` is deprecated,' + - ' use `uiDirs` instead'); - router.use(loopback.static(options.swaggerDistRoot)); - } - // File in node_modules are overridden by a few customizations router.use(loopback.static(STATIC_ROOT)); diff --git a/lib/class-helper.js b/lib/class-helper.js deleted file mode 100644 index 761b018..0000000 --- a/lib/class-helper.js +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; - -/** - * Module dependencies. - */ -var modelHelper = require('./model-helper'); -var typeConverter = require('./type-converter'); -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) { - 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('/', resourcePath), - apis: [], - consumes: aClass.http.consumes || opts.consumes, - produces: aClass.http.produces || opts.produces, - models: modelHelper.generateModelDefinition(aClass.ctor, {}) - }; - }, - /** - * 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) { - var description = aClass.ctor.settings.description || - aClass.ctor.sharedCtor && aClass.ctor.sharedCtor.description; - - return { - path: aClass.http.path, - description: typeConverter.convertText(description) - }; - } -}; diff --git a/lib/model-helper.js b/lib/model-helper.js index 6a6135c..c0a5863 100644 --- a/lib/model-helper.js +++ b/lib/model-helper.js @@ -3,11 +3,9 @@ /** * Module dependencies. */ -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 schemaBuilder = require('./schema-builder'); var typeConverter = require('./type-converter'); +var TypeRegistry = require('./type-registry'); /** * Export the modelHelper singleton. @@ -17,205 +15,103 @@ 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} modelClass Model class. - * @param {Object} definitions Model definitions + * @param {TypeRegistry} typeRegistry Registry of types and models. * @return {Object} Associated model definition. */ - generateModelDefinition: function generateModelDefinition(modelClass, definitions) { - var processType = function(app, modelName, referencedModels) { - if (app && modelName) { - if (modelName.indexOf('[') == 0) { - modelName = modelName.replace(/[\[\]]/g, ''); - } - var model = app.models[modelName]; - if (model && referencedModels.indexOf(model) === -1) { - referencedModels.push(model); - } - } - }; + registerModelDefinition: function(modelCtor, typeRegistry) { + var lbdef = modelCtor.definition; - var convertTypeTo$Ref = function convertTypeTo$Ref(prop){ - if (prop.type && TYPES_PRIMITIVE.indexOf(prop.type) === -1 ){ - prop.$ref = prop.type; - delete prop.type; - } - }; - - var def = modelClass.definition; - var out = definitions || {}; - - if (!def) { + if (!lbdef) { // The model does not have any definition, it was most likely // created as a placeholder for an unknown property type - return out; + return; } - var name = def.name; - if (out[name]) { + var name = lbdef.name; + if (typeRegistry.isDefined(name)) { // The model is already included - return out; + return; } - var required = []; - // Don't modify original properties. - var properties = _cloneDeep(def.rawProperties || def.properties); - var referencedModels = []; - // Add models from settings - if (def.settings && def.settings.models) { - for (var m in def.settings.models) { - var model = modelClass[m]; - if (typeof model === 'function' && model.modelName) { - if (model && referencedModels.indexOf(model) === -1) { - referencedModels.push(model); - } - } - } - } + var swaggerDef = { + description: typeConverter.convertText( + lbdef.description || (lbdef.settings && lbdef.settings.description)), + properties: {}, + required: [] + }; + + var properties = lbdef.rawProperties || lbdef.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 + // or as strings; swaggerSchema.builFromLoopBackType() will take + // care of the conversion. Object.keys(properties).forEach(function(key) { var prop = properties[key]; // Hide hidden properties. - if (modelHelper.isHiddenProperty(def, key)) { - delete properties[key]; + if (modelHelper.isHiddenProperty(lbdef, key)) return; - } // Eke a type out of the constructors we were passed. - 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 schema = schemaBuilder.buildFromLoopBackType(prop, typeRegistry); var desc = typeConverter.convertText(prop.description || prop.doc); - if (desc) swaggerType.description = desc; + if (desc) schema.description = desc; // Required props sit in a per-model array. if (prop.required || (prop.id && !prop.generated)) { - required.push(key); + swaggerDef.required.push(key); } - // Change mismatched keys. - prop = translateDataTypeKeys(prop); - - delete prop.required; - delete prop.id; - - if (prop.description){ - prop.description = typeConverter.convertText(prop.description); - } - - // Assign this back to the properties object. - properties[key] = swaggerType; - - var propType = prop.type; - if (typeof propType === 'function' && propType.modelName) { - if (referencedModels.indexOf(propType) === -1) { - referencedModels.push(propType); - } - } - if (Array.isArray(propType) && propType.length) { - var itemType = propType[0]; - if (typeof itemType === 'function' && itemType.modelName) { - if (referencedModels.indexOf(itemType) === -1) { - referencedModels.push(itemType); - } - } - } + // Assign the schema to the properties object. + swaggerDef.properties[key] = schema; }); - var additionalProperties = undefined; - if (def.settings){ - var strict = def.settings.strict; - additionalProperties = def.settings.additionalProperties; + if (lbdef.settings) { + var strict = lbdef.settings.strict; + var additionalProperties = lbdef.settings.additionalProperties; var notAllowAdditionalProperties = strict || (additionalProperties !== true); if (notAllowAdditionalProperties){ - additionalProperties = false; + swaggerDef.additionalProperties = false; } } - out[name] = { - id: name, - additionalProperties: additionalProperties, - description: typeConverter.convertText( - def.description || (def.settings && def.settings.description)), - properties: properties, - required: required - }; + if (!swaggerDef.required.length) { + // "required" must have at least one item when present + delete swaggerDef.required; + } - if (def.description){ - out[name].description = typeConverter.convertText(def.description); + typeRegistry.register(name, swaggerDef); + + // Add models from settings + if (lbdef.settings && lbdef.settings.models) { + for (var m in lbdef.settings.models) { + var model = modelCtor[m]; + if (typeof model !== 'function' || !model.modelName) continue; + modelHelper.registerModelDefinition(model, typeRegistry); + // TODO it shouldn't be necessary to reference the model here, + // let accepts/returns/property reference it instead + typeRegistry.reference(model.modelName); + } } // Generate model definitions for related models - for (var r in modelClass.relations) { - var rel = modelClass.relations[r]; - if (rel.modelTo && referencedModels.indexOf(rel.modelTo) === -1) { - referencedModels.push(rel.modelTo); + for (var r in modelCtor.relations) { + var rel = modelCtor.relations[r]; + if (rel.modelTo) { + modelHelper.registerModelDefinition(rel.modelTo, typeRegistry); + // TODO it shouldn't be necessary to reference the model here, + // let accepts/returns/property reference it instead + typeRegistry.reference(rel.modelTo.modelName); } - if (rel.modelThrough && referencedModels.indexOf(rel.modelThrough) === -1) { - referencedModels.push(rel.modelThrough); + if (rel.modelThrough) { + modelHelper.registerModelDefinition(rel.modelThrough, typeRegistry); + // TODO it shouldn't be necessary to reference the model here, + // let accepts/returns/property reference it instead + typeRegistry.reference(rel.modelThrough.modelName); } } - - if (modelClass.sharedClass) { - var remotes = modelClass.sharedClass.methods(); - for (var remoteIdx in remotes) { - var remote = remotes[remoteIdx]; - var accepts = remote.accepts; - if (accepts) { - for (var acceptIdx in accepts) { - processType(modelClass.app, accepts[acceptIdx].type, referencedModels); - } - } - var returns = remote.returns; - if (returns) { - for (var returnIdx in returns) { - processType(modelClass.app, returns[returnIdx].type, referencedModels); - } - } - var errors = remote.errors; - if (errors) { - for (var errorIdx in errors) { - processType(modelClass.app, errors[errorIdx].responseModel, referencedModels); - } - } - } - } - - for (var i = 0, n = referencedModels.length; i < n; i++) { - if (referencedModels[i].definition) { - generateModelDefinition(referencedModels[i], out); - } - } - 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') { - // See https://github.com/strongloop/loopback-explorer/issues/32 - // The type can be a model class - 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; }, isHiddenProperty: function(definition, propName) { @@ -223,67 +119,4 @@ var modelHelper = module.exports = { Array.isArray(definition.settings.hidden) && definition.settings.hidden.indexOf(propName) !== -1; }, - - // 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(ldlType) { - var SWAGGER_DATA_TYPE_FIELDS = [ - 'format', - 'defaultValue', - 'enum', - 'items', - 'minimum', - 'minItems', - 'minLength', - 'maximum', - 'maxItems', - 'maxLength', - 'uniqueItems', - // loopback-explorer extensions - 'length', - // https://www.npmjs.org/package/swagger-validation - 'pattern' - ]; - - // 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]; - - var newItems = null; - if (arrayItem) { - if(typeof arrayItem === 'object') { - newItems = modelHelper.LDLPropToSwaggerDataType(arrayItem); - } else { - newItems = { type: modelHelper.getPropType(arrayItem) }; - } - } else { - // NOTE: `any` is not a supported type in swagger 1.2 - newItems = { type: 'any' }; - } - if (typeof swaggerType.items !== 'object') { - swaggerType.items = {}; - } - for (var key in newItems) { - swaggerType.items[key] = newItems[key]; - } - } 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 swaggerType; - } -}; \ No newline at end of file +}; diff --git a/lib/route-helper.js b/lib/route-helper.js index 4c35f97..8dea0e8 100644 --- a/lib/route-helper.js +++ b/lib/route-helper.js @@ -5,10 +5,9 @@ */ var debug = require('debug')('loopback:explorer:routeHelpers'); -var _cloneDeep = require('lodash').cloneDeep; var _assign = require('lodash').assign; -var modelHelper = require('./model-helper'); var typeConverter = require('./type-converter'); +var schemaBuilder = require('./schema-builder'); /** * Export the routeHelper singleton. @@ -17,46 +16,46 @@ 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. - * + * add it as a new operation under that path entry. + * * 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. + * `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. + * @param {TypeRegistry} typeRegistry Registry of types and models. + * @param {Object} paths Swagger Path Object, + * see https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#pathsObject */ - 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); + addRouteToSwaggerPaths: function(route, classDef, typeRegistry, paths) { + var entryToAdd = routeHelper.routeToPathEntry(route, classDef, + typeRegistry); + if (!(entryToAdd.path in paths)) { + paths[entryToAdd.path] = {}; } - }, + paths[entryToAdd.path][entryToAdd.method] = entryToAdd.operation; + }, /** * Massage route.accepts. * @param {Object} route Strong Remoting Route object. * @param {Class} classDef Strong Remoting class. + * @param {TypeRegistry} typeRegistry Registry of types and models. * @return {Array} Array of param docs. */ - convertAcceptsToSwagger: function convertAcceptsToSwagger(route, classDef) { + convertAcceptsToSwagger: function(route, classDef, typeRegistry) { + var accepts = route.accepts || []; var split = route.method.split('.'); - var accepts = _cloneDeep(route.accepts) || []; - if (classDef && classDef.sharedCtor && + 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){ + accepts = accepts.filter(function(arg) { if (!arg.http) return true; // Don't show derived arguments. if (typeof arg.http === 'function') return false; @@ -72,7 +71,8 @@ var routeHelper = module.exports = { }); // Turn accept definitions in to parameter docs. - accepts = accepts.map(routeHelper.acceptToParameter(route)); + accepts = accepts.map( + routeHelper.acceptToParameter(route, classDef, typeRegistry)); return accepts; }, @@ -80,136 +80,94 @@ var routeHelper = module.exports = { /** * 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 = [classDef.name]; - } + convertReturnsToSwagger: function(route, typeRegistry) { + var routeReturns = route.returns; + if (!routeReturns || !routeReturns.length) { + // An operation that returns nothing will have + // no schema declaration for its response. + return undefined; + } + + if (routeReturns.length === 1 && routeReturns[0].root) { + if (routeReturns[0].model) + return { $ref: typeRegistry.reference(routeReturns[0].model) }; + return schemaBuilder.buildFromLoopBackType(routeReturns[0], typeRegistry); } // Convert `returns` into a single object for later conversion into an // operation object. - if (routeReturns && routeReturns.length > 1) { - // TODO ad-hoc model definition in the case of multiple return values. - routeReturns = { type: 'object' }; - } else { - // 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 routeReturns; + // TODO ad-hoc model definition in the case of multiple return values. + // It is enough to replace 'object' with an anonymous type definition + // based on all routeReturn items. The schema converter should take + // care of the remaning conversions. + var def = { type: 'object' }; + return schemaBuilder.buildFromLoopBackType(def, typeRegistry); }, /** * 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 + * Swagger-formatted "Path Item Object" + * See swagger-spec/2.0.md#pathItemObject */ - routeToAPIDoc: function routeToAPIDoc(route, classDef) { - /** - * Converts from an sl-remoting data type to a Swagger dataType. - */ - function prepareDataType(type) { - if (!type) { - return 'void'; - } - - if(Array.isArray(type)) { - if (type.length > 0) { - if (typeof type[0] === 'string') { - return '[' + type[0] + ']'; - } else if (typeof type[0] === 'function') { - return '[' + type[0].name + ']'; - } else if (typeof type[0] === 'object') { - if (typeof type[0].type === 'function') { - return '[' + type[0].type.name + ']'; - } else { - return '[' + type[0].type + ']'; - } - } else { - return '[' + type + ']'; - } - } - return 'array'; - } - - // TODO(schoon) - Add support for complex dataTypes, "models", etc. - switch (type) { - case 'Array': - return 'array'; - case 'Boolean': - return 'boolean'; - case 'buffer': - return 'string'; - case 'Date': - return 'date'; - case 'number': - case 'Number': - return 'double'; - case 'Object': - return 'object'; - case 'String': - return 'string'; - } - - return type; - } - - var returnDesc; - - // Some parameters need to be altered; eventually most of this should + routeToPathEntry: function(route, classDef, typeRegistry) { + // 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); - var responseMessages = [ - { - code: route.returns && route.returns.length ? 200 : 204, - message: 'Request was successful', - responseModel: returns.model || prepareDataType(returns.type) || 'void' - } - ]; + var accepts = routeHelper.convertAcceptsToSwagger(route, classDef, + typeRegistry); + var returns = routeHelper.convertReturnsToSwagger(route, typeRegistry); + var defaultCode = route.returns && route.returns.length ? 200 : 204; + // TODO - support strong-remoting's option for a custom response code + + var responseMessages = {}; + responseMessages[defaultCode] = { + description: 'Request was successful', + schema: returns, + // TODO - headers, examples + }; + if (route.errors) { - responseMessages.push.apply(responseMessages, route.errors); + // TODO define new LDL syntax that is status-code-indexed + // and which allow users to specify headers & examples + route.errors.forEach(function(msg) { + responseMessages[msg.code] = { + description: msg.message, + schema: schemaBuilder.buildFromLoopBackType(msg.responseModel, + typeRegistry), + // TODO - headers, examples + }; + }); } debug('route %j', route); - var responseDoc = modelHelper.LDLPropToSwaggerDataType(returns); + var tags = []; + if (classDef && classDef.name) { + tags.push(classDef.name); + } - var apiDoc = { + var entry = { path: routeHelper.convertPathFragments(route.path), - // 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), - // [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, - consumes: ['application/json', 'application/xml', 'text/xml'], - produces: ['application/json', 'application/javascript', 'application/xml', 'text/javascript', 'text/xml'], - parameters: accepts, - responseMessages: responseMessages, - type: returns.model || returns.type || 'void', + method: routeHelper.convertVerb(route.verb), + operation: { + tags: tags, summary: typeConverter.convertText(route.description), - notes: typeConverter.convertText(route.notes) - }, returns)] + description: typeConverter.convertText(route.notes), + // [bajtos] We used to remove leading model name from the operation + // name for Swagger Spec 1.2. Swagger Spec 2.0 requires + // operation ids to be unique, thus we have to include the model name. + operationId: route.method, + // [bajtos] we are omitting consumes and produces, as they are same + // for all methods and they are already specified in top-level fields + parameters: accepts, + responses: responseMessages, + deprecated: !!route.deprecated, + // TODO: security + } }; - return apiDoc; + return entry; }, convertPathFragments: function convertPathFragments(path) { @@ -223,30 +181,27 @@ var routeHelper = module.exports = { convertVerb: function convertVerb(verb) { if (verb.toLowerCase() === 'all') { - return 'POST'; + return 'post'; } if (verb.toLowerCase() === 'del') { - return 'DELETE'; + return 'delete'; } - return verb.toUpperCase(); + return verb.toLowerCase(); }, /** - * A generator to convert from an sl-remoting-formatted "Accepts" description + * 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'; - } + acceptToParameter: function acceptToParameter(route, classDef, typeRegistry) { + var DEFAULT_TYPE = + route.verb.toLowerCase() === 'get' ? 'query' : 'formData'; return function (accepts) { var name = accepts.name || accepts.arg; - var paramType = type; + var paramType = DEFAULT_TYPE; // TODO: Regex. This is leaky. if (route.path.indexOf(':' + name) !== -1) { @@ -254,78 +209,44 @@ var routeHelper = module.exports = { } // Check the http settings for the argument - if(accepts.http && accepts.http.source) { - paramType = accepts.http.source; + if (accepts.http && accepts.http.source) { + paramType = accepts.http.source; } - var out = { + // TODO: ensure that paramType has a valid value + // path, query, header, body, formData + // See swagger-spec/2.0.md#parameterObject + + var paramObject = { name: name, - required: !!accepts.required, - paramType: paramType || type, - type: accepts.type, - $ref: accepts.model, - items: accepts.items, - uniqueItems: accepts.uniqueItems, - format: accepts.format, - pattern: accepts.pattern, - defaultValue: accepts.defaultValue, - enum: accepts.enum, - minimum: accepts.minimum, - maximum: accepts.maximum, - allowMultiple: accepts.allowMultiple, - description: typeConverter.convertText(accepts.description) + in: paramType, + description: typeConverter.convertText(accepts.description), + required: !!accepts.required }; - out = routeHelper.extendWithType(out, accepts); - - // 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. - * @param {Object} ldlType LDL type definition - * @return {Object} Extended object. - */ - extendWithType: function extendWithType(obj, ldlType) { - obj = _cloneDeep(obj); - - // Format the `type` property using our LDL converter. - 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; + var schema = schemaBuilder.buildFromLoopBackType(accepts, typeRegistry); + if (paramType === 'body') { + // HACK: Derive the type from model + if (paramObject.name === 'data' && schema.type === 'object') { + paramObject.schema = { $ref: typeRegistry.reference(classDef.name) }; + } else { + paramObject.schema = schema; + } + } else { + var isComplexType = schema.type === 'object' || + schema.type === 'array' || + schema.$ref; + if (isComplexType) { + paramObject.type = 'string'; + paramObject.format = 'JSON'; + // TODO support array of primitive types + // and map them to Swagger array of primitive types + } else { + _assign(paramObject, schema); } } - } - _assign(obj, typeDesc); - - return obj; - } + return paramObject; + }; + }, }; - - diff --git a/lib/schema-builder.js b/lib/schema-builder.js new file mode 100644 index 0000000..512046c --- /dev/null +++ b/lib/schema-builder.js @@ -0,0 +1,170 @@ +'use strict'; + +var assert = require('assert'); +var typeConverter = require('./type-converter'); + +var TYPES_PRIMITIVE = [ + 'boolean', + 'integer', + 'number', + 'null', + 'string', + 'object', + 'array' +]; + +var KEY_TRANSLATIONS = { + // LDL : Swagger + min: 'minimum', + max: 'maximum', + length: 'maxLength', +}; + +var SWAGGER_DATA_TYPE_FIELDS = [ + 'format', + 'default', + 'enum', + 'minimum', + 'minItems', + 'minLength', + 'maximum', + 'maxItems', + 'maxLength', + 'uniqueItems', + 'pattern' +]; + +/** + * Build a Swagger Schema Object and/or Parameter Object from LoopBack + * type descriptor. + * + * @param {String|Function|Array|Object} ldlDef The loopback type to convert, + * the value should be one of the following: + * - a string value (type name), e.g. `'string'` or `'MyModel'` + * - a constructor function, e.g. `String` or `MyModel` + * - an array of a single item in `lbType` format + * - an object containing a `type` property with string/function/array value + * and validation fields like `length` or `max` + * @param {TypeRegistry} typeRegistry The registry of known types and models. + * @returns {Object} Swagger Schema Object that can be used as `schema` field + * or as a base for Parameter Object. + */ +exports.buildFromLoopBackType = function(ldlDef, typeRegistry) { + assert(!!typeRegistry, 'typeRegistry is a required parameter'); + + // Normalize non-object values to object format `{ type: XYZ }` + if (typeof ldlDef === 'string' || typeof ldlDef === 'function') { + ldlDef = { type: ldlDef }; + } else if (Array.isArray(ldlDef)) { + ldlDef = { type: ldlDef }; + } + + var schema = exports.buildMetadata(ldlDef); + var ldlType = exports.getLdlTypeName(ldlDef.type); + + if (Array.isArray(ldlType)) { + var itemLdl = ldlType[0] || 'any'; + var itemSchema = exports.buildFromLoopBackType(itemLdl, typeRegistry); + schema.type = 'array'; + schema.items = itemSchema; + return schema; + } + + var ldlTypeLowerCase = ldlType.toLowerCase(); + switch (ldlTypeLowerCase) { + case 'date': + schema.type = 'string'; + schema.format = 'date'; + break; + case 'buffer': + schema.type = 'string'; + schema.format = 'byte'; + break; + case 'number': + schema.type = 'number'; + schema.format = schema.format || 'double'; // All JS numbers are doubles + break; + case 'any': + schema.$ref = typeRegistry.reference('x-any'); + break; + default: + if (exports.isPrimitiveType(ldlTypeLowerCase)) { + schema.type = ldlTypeLowerCase; + } else { + // TODO - register anonymous types + schema.$ref = typeRegistry.reference(ldlType); + } + } + return schema; +}; + +/** + * @param {String|Function|Array|Object} ldlType LDL type + * @returns {String|Array} Type name + */ +exports.getLdlTypeName = function(ldlType) { + // Value "array" is a shortcut for `['any']` + if (ldlType === 'array') { + return ['any']; + } + + if (typeof ldlType === 'string') { + var arrayMatch = ldlType.match(/^\[(.*)\]$/); + return arrayMatch ? [arrayMatch[1]] : ldlType; + } + + if (typeof ldlType === 'function') { + return ldlType.modelName || ldlType.name; + } + + if (Array.isArray(ldlType)) { + return ldlType; + } + + if (typeof ldlType === 'object') { + // Anonymous objects, they are allowed e.g. in accepts/returns definitions + // TODO(bajtos) Build a named schema for this anonymous object + return 'object'; + } + + if (ldlType === undefined) { + return 'any'; + } + + console.error('Warning: unknown LDL type %j, using "any" instead', ldlType); + return 'any'; +}; + +/** + * Convert validations and other metadata from LDL format to Swagger format. + * @param {Object} ldlDef LDL property/argument definition, + * for example `{ type: 'string', maxLength: 64 }`. + * @return {Object} Metadata in Swagger format. + */ +exports.buildMetadata = function(ldlDef) { + var result = {}; + var key; + + for (key in KEY_TRANSLATIONS) { + if (key in ldlDef) + result[KEY_TRANSLATIONS[key]] = ldlDef[key]; + } + + for (var ix in SWAGGER_DATA_TYPE_FIELDS) { + key = SWAGGER_DATA_TYPE_FIELDS[ix]; + if (key in ldlDef) + result[key] = ldlDef[key]; + } + + if (ldlDef.description) { + result.description = typeConverter.convertText(ldlDef.description); + } else if (ldlDef.doc) { + result.description = typeConverter.convertText(ldlDef.doc); + } + + return result; +}; + +exports.isPrimitiveType = function(typeName) { + return TYPES_PRIMITIVE.indexOf(typeName.toLowerCase()) !== -1; +}; diff --git a/lib/swagger.js b/lib/swagger.js index 51e4755..8223c81 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -1,38 +1,27 @@ 'use strict'; -/** - * Expose the `Swagger` plugin. - */ -module.exports = Swagger; /** * Module dependencies. */ var path = require('path'); -var urlJoin = require('./url-join'); -var _defaults = require('lodash').defaults; -var classHelper = require('./class-helper'); +var _ = require('lodash'); var routeHelper = require('./route-helper'); -var _cloneDeep = require('lodash').cloneDeep; var modelHelper = require('./model-helper'); var cors = require('cors'); var typeConverter = require('./type-converter'); +var tagBuilder = require('./tag-builder'); +var TypeRegistry = require('./type-registry'); /** - * Create a remotable Swagger module for plugging into `RemoteObjects`. + * Create Swagger Object describing the API provided by loopbacApplication. * - * @param {Application} loopbackApplication Host loopback application. - * @param {Application} swaggerApp Swagger application used for hosting - * these files. + * @param {Application} loopbackApplication The application to document. * @param {Object} opts Options. + * @returns {Object} */ -function Swagger(loopbackApplication, swaggerApp, opts) { - if (opts && opts.swaggerVersion) - console.warn('loopback-explorer\'s options.swaggerVersion is deprecated.'); - - opts = _defaults(opts || {}, { - swaggerVersion: '1.2', +exports.createSwaggerObject = function(loopbackApplication, opts) { + opts = _.defaults(opts || {}, { basePath: loopbackApplication.get('restApiRoot') || '/api', - resourcePath: 'resources', // Default consumes/produces consumes: [ 'application/json', @@ -45,7 +34,7 @@ function Swagger(loopbackApplication, swaggerApp, opts) { // JSONP content types 'application/javascript', 'text/javascript' ], - version: getVersion() + version: getPackagePropertyOrDefault('version', '1.0.0'), }); // We need a temporary REST adapter to discover our available routes. @@ -54,151 +43,79 @@ function Swagger(loopbackApplication, swaggerApp, opts) { var routes = adapter.allRoutes(); var classes = remotes.classes(); - setupCors(swaggerApp, remotes); + // Generate fixed fields like info and basePath + var swaggerObject = generateSwaggerObjectBase(opts); - // These are the docs we will be sending from the /swagger endpoints. - var resourceDoc = generateResourceDoc(opts); - var apiDocs = {}; + var typeRegistry = new TypeRegistry(); + var loopbackRegistry = loopbackApplication.registry || + loopbackApplication.loopback.registry || + loopbackApplication.loopback; + var models = loopbackRegistry.modelBuilder.models; + for (var modelName in models) { + modelHelper.registerModelDefinition(models[modelName], typeRegistry); + } // 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 hasDocumented = false; - var methods = aClass.methods() - for (var methodKey in methods) { - hasDocumented = methods[methodKey].documented; - if (hasDocumented) { - break; - } - } - if (hasDocumented) { - resourceDoc.apis.push(classHelper.generateResourceDocAPIEntry(aClass)); - } + // In Swagger 2.0, there is no endpoint roots, but one can group endpoints + // using tags. + classes.forEach(function(aClass) { + if (!aClass.name) return; - // Add the getter for this doc. - var docPath = urlJoin(opts.resourcePath, aClass.http.path); - addRoute(swaggerApp, docPath, doc, opts); + var hasDocumentedMethods = aClass.methods().some(function(m) { + return m.documented; + }); + if (!hasDocumentedMethods) return; + + swaggerObject.tags.push(tagBuilder.buildTagFromClass(aClass)); }); // 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; - } + if (!route.documented) return; + // Get the class definition matching this route. - var classDef = classes.filter(function (item) { + var className = route.method.split('.')[0]; + var classDef = classes.filter(function(item) { return item.name === className; })[0]; - if (route.documented) { - routeHelper.addRouteToAPIDeclaration(route, classDef, doc); + if (!classDef) { + console.error('Route exists with no class: %j', route); + return; } + + routeHelper.addRouteToSwaggerPaths(route, classDef, typeRegistry, + swaggerObject.paths); }); - // 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; + _.assign(swaggerObject.definitions, typeRegistry.getDefinitions()); - 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 - * information about them. - */ - addRoute(swaggerApp, opts.resourcePath, resourceDoc, opts); - - loopbackApplication.emit('swaggerResources', resourceDoc); -} - -function setupCors(swaggerApp, remotes) { - var corsOptions = remotes.options && remotes.options.cors || - { origin: true, credentials: true }; - - swaggerApp.use(cors(corsOptions)); -} + loopbackApplication.emit('swaggerResources', swaggerObject); + return swaggerObject; +}; /** - * 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. + * Setup Swagger documentation on the given express app. + * + * @param {Application} loopbackApplication The loopback application to + * document. + * @param {Application} swaggerApp Swagger application used for hosting + * swagger documentation. + * @param {Object} opts Options. */ -function addRoute(app, uri, doc, opts) { +exports.mountSwagger = function(loopbackApplication, swaggerApp, opts) { + var swaggerObject = exports.createSwaggerObject(loopbackApplication, opts); - var hasBasePath = Object.keys(doc).indexOf('basePath') !== -1; - var initialPath = doc.basePath || ''; + var resourcePath = opts && opts.resourcePath || 'swagger.json'; + if (resourcePath[0] !== '/') resourcePath = '/' + resourcePath; - // Remove the trailing slash, see - // https://github.com/strongloop/loopback-explorer/issues/48 - if (initialPath[initialPath.length-1] === '/') - initialPath = initialPath.slice(0, -1); + var remotes = loopbackApplication.remotes(); + setupCors(swaggerApp, remotes); - 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` 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; - // NOTE header names (keys) are always all-lowercase - var proto = headers['x-forwarded-proto'] || opts.protocol || req.protocol; - var prefix = opts.omitProtocolInBaseUrl ? '//' : proto + '://'; - var host = headers['x-forwarded-host'] || opts.host || headers.host; - doc.basePath = prefix + host + initialPath; - } - res.status(200).send(doc); + swaggerApp.get(resourcePath, function sendSwaggerObject(req, res) { + res.status(200).send(swaggerObject); }); -} +}; /** * Generate a top-level resource doc. This is the entry point for swagger UI @@ -206,37 +123,51 @@ function addRoute(app, uri, doc, opts) { * @param {Object} opts Swagger options. * @return {Object} Resource doc. */ -function generateResourceDoc(opts) { - var apiInfo = _cloneDeep(opts.apiInfo); +function generateSwaggerObjectBase(opts) { + var apiInfo = _.cloneDeep(opts.apiInfo) || {}; for (var propertyName in apiInfo) { var property = apiInfo[propertyName]; apiInfo[propertyName] = typeConverter.convertText(property); } + apiInfo.version = String(apiInfo.version || opts.version); + if (!apiInfo.title) { + apiInfo.title = getPackagePropertyOrDefault('name', 'LoopBack Application'); + } + + var basePath = opts.basePath; + if (basePath && /\/$/.test(basePath)) + basePath = basePath.slice(0, -1); return { - swaggerVersion: opts.swaggerVersion, - apiVersion: opts.version, - // See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#513-info-object + swagger: '2.0', + // See swagger-spec/2.0.md#infoObject info: apiInfo, - // TODO Authorizations - // https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#514-authorizations-object - consumes: ['application/json', 'application/xml', 'text/xml'], - produces: ['application/json', 'application/javascript', 'application/xml', 'text/javascript', 'text/xml'], - apis: [], - models: opts.models + host: opts.host, + basePath: basePath, + schemes: opts.protocol ? [opts.protocol] : undefined, + consumes: opts.consumes, + produces: opts.produces, + paths: {}, + definitions: opts.models || {}, + // TODO Authorizations (security, securityDefinitions) + // TODO: responses, externalDocs + tags: [] }; } -/** - * 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 = '1.0.0'; - } - return version; +function setupCors(swaggerApp, remotes) { + var corsOptions = remotes.options && remotes.options.cors || + { origin: true, credentials: true }; + + // TODO(bajtos) Skip CORS when remotes.options.cors === false + swaggerApp.use(cors(corsOptions)); +} + +function getPackagePropertyOrDefault(name, defautValue) { + try { + var pkg = require(path.join(process.cwd(), 'package.json')); + return pkg[name] || defautValue; + } catch(e) { + return defautValue; + } } diff --git a/lib/tag-builder.js b/lib/tag-builder.js new file mode 100644 index 0000000..515ad25 --- /dev/null +++ b/lib/tag-builder.js @@ -0,0 +1,18 @@ +'use strict'; + +var typeConverter = require('./type-converter'); + +exports.buildTagFromClass = function(sharedClass) { + var name = sharedClass.name; + var modelSettings = sharedClass.ctor && sharedClass.ctor.settings; + var sharedCtor = sharedClass.ctor && sharedClass.ctor.sharedCtor; + + var description = modelSettings && modelSettings.description || + sharedCtor && sharedCtor.description; + + return { + name: name, + description: typeConverter.convertText(description), + // TODO: externalDocs: { description, url } + }; +}; diff --git a/lib/translate-data-type-keys.js b/lib/translate-data-type-keys.js deleted file mode 100644 index d2ca63a..0000000 --- a/lib/translate-data-type-keys.js +++ /dev/null @@ -1,37 +0,0 @@ -'use strict'; - -/** - * Module dependencies. - */ - -var _cloneDeep = require('lodash').cloneDeep; - -// Keys that are different between LDL and Swagger -var KEY_TRANSLATIONS = { - // LDL : Swagger - '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/type-converter.js b/lib/type-converter.js index c7eb686..66e1439 100644 --- a/lib/type-converter.js +++ b/lib/type-converter.js @@ -1,3 +1,5 @@ +'use strict'; + var typeConverter = module.exports = { /** diff --git a/lib/type-registry.js b/lib/type-registry.js new file mode 100644 index 0000000..cb3456d --- /dev/null +++ b/lib/type-registry.js @@ -0,0 +1,43 @@ +'use strict'; + +var _ = require('lodash'); + +module.exports = TypeRegistry; + +function TypeRegistry() { + this._definitions = Object.create(null); + this._referenced = Object.create(null); + + this.register('x-any', { properties: {} }); + // TODO - register GeoPoint and other built-in LoopBack types +} + +TypeRegistry.prototype.register = function(typeName, definition) { + this._definitions[typeName] = definition; +}; + +TypeRegistry.prototype.reference = function(typeName) { + this._referenced[typeName] = true; + return '#/definitions/' + typeName; +}; + +TypeRegistry.prototype.getDefinitions = function() { + var defs = Object.create(null); + for (var name in this._referenced) { + if (this._definitions[name]) { + defs[name] = _.cloneDeep(this._definitions[name]); + } else { + // https://github.com/strongloop/loopback-explorer/issues/71 + console.warn('Swagger: skipping unknown type %j.', name); + } + } + return defs; +}; + +TypeRegistry.prototype.getAllDefinitions = function() { + return _.cloneDeep(this._definitions); +}; + +TypeRegistry.prototype.isDefined = function(typeName) { + return typeName in this._definitions; +}; diff --git a/test/class-helper.test.js b/test/class-helper.test.js deleted file mode 100644 index 3bb27b1..0000000 --- a/test/class-helper.test.js +++ /dev/null @@ -1,82 +0,0 @@ -'use strict'; - -var classHelper = require('../lib/class-helper'); -var expect = require('chai').expect; -var _defaults = require('lodash').defaults; - -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() { - var aClass = { - ctor: { - settings: { - description: ['1','2','3'] - } - }, - http:{ - path: 'path' - } - }; - - var result = classHelper.generateResourceDocAPIEntry(aClass); - expect(result.description).to.eql("1\n2\n3"); - }); - }); - - describe('when ctor.sharedCtor.description is an array of string', function() { - it('should return description as a string', function() { - var aClass = { - ctor: { - settings: {}, - sharedCtor: { - description: ['1','2','3'] - } - }, - http:{ - path: 'path' - } - }; - - var result = classHelper.generateResourceDocAPIEntry(aClass); - expect(result.description).to.eql("1\n2\n3"); - }); - }); - }); -}); - -// 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 c3dd9bb..42477b1 100644 --- a/test/explorer.test.js +++ b/test/explorer.test.js @@ -41,7 +41,7 @@ describe('explorer', function() { .end(function(err, res) { if (err) return done(err); expect(res.body).to - .have.property('url', '/explorer/resources'); + .have.property('url', '/explorer/swagger.json'); done(); }); }); @@ -58,7 +58,7 @@ describe('explorer', function() { .end(function(err, res) { if (err) return done(err); expect(res.body).to - .have.property('url', '/swagger/resources'); + .have.property('url', '/swagger/swagger.json'); done(); }); }); @@ -76,7 +76,7 @@ describe('explorer', function() { .end(function(err, res) { if (err) return done(err); expect(res.body).to - .have.property('url', '/explorer/resources'); + .have.property('url', '/explorer/swagger.json'); done(); }); }); @@ -86,17 +86,17 @@ describe('explorer', function() { // 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', '/'); + app.set('restApiRoot', '/apis/'); configureRestApiAndExplorer(app); request(app) - .get('/explorer/resources/products') + .get('/explorer/swagger.json') .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/); + var apiPath = Object.keys(res.body.paths)[0]; + expect(baseUrl + apiPath).to.equal('/apis/products'); done(); }); }); @@ -107,7 +107,7 @@ describe('explorer', function() { beforeEach(function setupExplorerWithUiDirs() { app = loopback(); explorer(app, { - uiDirs: [ path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui') ] + uiDirs: [path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui')] }); }); @@ -157,7 +157,7 @@ describe('explorer', function() { it('should allow `uiDirs` to be defined as an Array', function(done) { explorer(app, { - uiDirs: [ path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui') ] + uiDirs: [path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui')] }); request(app).get('/explorer/') @@ -194,6 +194,7 @@ describe('explorer', function() { app.model(Product); explorer(app, { mountPath: explorerBase }); + app.set('legacyExplorer', false); app.use(app.get('restApiRoot') || '/', loopback.rest()); } }); diff --git a/test/model-helper.test.js b/test/model-helper.test.js index 7de1b1f..2401889 100644 --- a/test/model-helper.test.js +++ b/test/model-helper.test.js @@ -1,153 +1,14 @@ 'use strict'; var modelHelper = require('../lib/model-helper'); +var TypeRegistry = require('../lib/type-registry'); var _defaults = require('lodash').defaults; var loopback = require('loopback'); var expect = require('chai').expect; describe('model-helper', function() { - describe('properly converts LDL definitions to swagger types', function() { - it('converts constructor types', function() { - var def = buildSwaggerModels({ - str: String, // 'string' - num: Number, // {type: 'number', format: 'double'} - date: Date, // {type: 'string', format: 'date'} - bool: Boolean, // 'boolean' - buf: Buffer // {type: 'string', format: 'byte'} - }); - var props = def.properties; - expect(props.str).to.eql({ type: 'string' }); - expect(props.num).to.eql({ type: 'number', format: 'double' }); - expect(props.date).eql({ type: 'string', format: 'date' }); - expect(props.bool).to.eql({ type: 'boolean' }); - expect(props.buf).to.eql({ type: 'string', format: 'byte' }); - }); - it('converts string types', function() { - var def = buildSwaggerModels({ - str: 'string', // 'string' - num: 'number', // {type: 'number', format: 'double'} - date: 'date', // {type: 'string', format: 'date'} - bool: 'boolean', // 'boolean' - buf: 'buffer' // {type: 'string', format: 'byte'} - }); - var props = def.properties; - expect(props.str).to.eql({ type: 'string' }); - expect(props.num).to.eql({ type: 'number', format: 'double' }); - expect(props.date).eql({ type: 'string', format: 'date' }); - expect(props.bool).to.eql({ type: 'boolean' }); - expect(props.buf).to.eql({ type: 'string', format: 'byte' }); - }); - describe('array definitions', function() { - // There are three types we want to checK: - // [String] - // ["string"], - // [{type: String, ...}] - it('converts [Constructor] type', function() { - var def = buildSwaggerModels({ - array: [String] - }); - var props = def.properties; - expect(props.array).to.eql({ type: 'array', items: { - type: 'string' - }}); - }); - - it('converts ["string"] type', function() { - var def = buildSwaggerModels({ - array: ['string'] - }); - var props = def.properties; - expect(props.array).to.eql({ type: 'array', items: { - type: 'string' - }}); - }); - - it('converts [{type: "string", length: 64}] type', function() { - var def = buildSwaggerModels({ - array: [{type: 'string', length: 64}] - }); - var props = def.properties; - expect(props.array).to.eql({ type: 'array', items: { - type: 'string', - length: 64 - }}); - }); - - it('converts [{type: "date"}] type (with `format`)', function() { - var def = buildSwaggerModels({ - array: [{type: 'date'}] - }); - var props = def.properties; - expect(props.array).to.eql({ type: 'array', items: { - type: 'string', format: 'date' - }}); - }); - - it('converts [] type', function() { - var def = buildSwaggerModels({ - array: [] - }); - var prop = def.properties.array; - expect(prop).to.eql({ - type: 'array', - items: { type: 'any' } - }); - }); - - it('converts [undefined] type', function() { - var def = buildSwaggerModels({ - // This value is somehow provided by loopback-boot called from - // loopback-workspace. - array: [undefined] - }); - var prop = def.properties.array; - expect(prop).to.eql({ type: 'array', items: { type: 'any' } }); - }); - - it('converts "array" type', function() { - var def = buildSwaggerModels({ - array: 'array' - }); - var prop = def.properties.array; - expect(prop).to.eql({ type: 'array', items: { type: 'any' } }); - }); - - it('converts Model type to $ref', function() { - var Address = loopback.createModel('Address', {street: String}); - var def = buildSwaggerModels({ - str: String, - address: Address - }); - var prop = def.properties.address; - expect(prop).to.eql({ $ref: 'Address' }); - }); - - }); - - 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() { - it('should include related models', function () { + it('should include related models', function() { var defs = buildSwaggerModelsWithRelations({ str: String // 'string' }); @@ -161,7 +22,7 @@ describe('model-helper', function() { str: String, // 'string' address: Model2 }, { models: { Model2: Model2 } }); - var defs = modelHelper.generateModelDefinition(Model1, {}); + var defs = getDefinitionsForModel(Model1); expect(defs).has.property('Model1'); expect(defs).has.property('Model2'); }); @@ -171,7 +32,7 @@ describe('model-helper', function() { var Model3 = loopback.createModel('Model3', { str: String // 'string' }, {models: {model4: 'Model4'}}); - var defs = modelHelper.generateModelDefinition(Model3, {}); + var defs = getDefinitionsForModel(Model3); expect(defs).has.property('Model3'); expect(defs).has.property('Model4'); }); @@ -182,7 +43,7 @@ describe('model-helper', function() { str: String, // 'string' addresses: [Model6] }, { models: { Model6: Model6 } }); - var defs = modelHelper.generateModelDefinition(Model5, {}); + var defs = getDefinitionsForModel(Model5); expect(defs).has.property('Model5'); expect(defs).has.property('Model6'); }); @@ -193,7 +54,7 @@ describe('model-helper', function() { var Model7 = loopback.createModel('Model7', {street: String}); Array.prototype.customFunc = function() { }; - var defs = modelHelper.generateModelDefinition(Model7, {}); + var defs = getDefinitionsForModel(Model7); expect(defs).has.property('Model7'); expect(Object.keys(defs)).has.property('length', 1); }); @@ -207,7 +68,11 @@ describe('model-helper', function() { through: 'appointment' } }); - var defs = modelHelper.generateModelDefinition(Model8, {}); + var defs = getDefinitionsForModel(Model8); + // Hack: prevent warnings in other tests caused by global model registry + Model8.definition.rawProperties.patient.type = 'string'; + Model8.definition.properties.patient.type = 'string'; + expect(Object.keys(defs)).to.not.contain('hasMany'); }); }); @@ -221,47 +86,42 @@ describe('model-helper', function() { aClass.ctor.definition.settings = { hidden: ['hiddenProperty'] }; - var def = modelHelper.generateModelDefinition(aClass.ctor, {}).testModel; + var def = getDefinitionsForModel(aClass.ctor).testModel; expect(def.properties).to.not.have.property('hiddenProperty'); expect(def.properties).to.have.property('visibleProperty'); }); }); - describe('#generateModelDefinition', function() { - it('should convert top level array description to string', function () { - var model = {}; - model.definition = { - name: 'test', - description: ['1', '2', '3'], - properties: {} - }; - var models = {}; - modelHelper.generateModelDefinition(model, models); - expect(models.test.description).to.equal("1\n2\n3"); - }); - - it('should convert property level array description to string', function () { - var model = {}; - model.definition = { - name: 'test', - properties: { - prop1: { - type: 'string', - description: ['1', '2', '3'] - } - } - }; - var models = {}; - modelHelper.generateModelDefinition(model, models); - expect(models.test.properties.prop1.description).to.equal("1\n2\n3"); - }); + it('should convert top level array description to string', function() { + var model = {}; + model.definition = { + name: 'test', + description: ['1', '2', '3'], + properties: {} + }; + var defs = getDefinitionsForModel(model); + expect(defs.test.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'); - }); + it('should convert property level array description to string', function() { + var model = {}; + model.definition = { + name: 'test', + properties: { + prop1: { + type: 'string', + description: ['1', '2', '3'] + } + } + }; + var defs = getDefinitionsForModel(model); + expect(defs.test.properties.prop1.description).to.equal('1\n2\n3'); + }); + + it('omits empty "required" array', function() { + var aClass = createModelCtor({}); + var def = getDefinitionsForModel(aClass.ctor).testModel; + expect(def).to.not.have.property('required'); }); }); @@ -319,6 +179,15 @@ function buildSwaggerModelsWithRelations(model) { } } }; - return modelHelper.generateModelDefinition(aClass.ctor, {}); + + var registry = new TypeRegistry(); + modelHelper.registerModelDefinition(aClass.ctor, registry); + return registry.getAllDefinitions(); } +function getDefinitionsForModel(modelCtor) { + var registry = new TypeRegistry(); + modelHelper.registerModelDefinition(modelCtor, registry); + registry.reference(modelCtor.modelName || modelCtor.definition.name); + return registry.getDefinitions(); +} diff --git a/test/route-helper.test.js b/test/route-helper.test.js index c9e01b1..920b99b 100644 --- a/test/route-helper.test.js +++ b/test/route-helper.test.js @@ -1,33 +1,35 @@ 'use strict'; var routeHelper = require('../lib/route-helper'); +var TypeRegistry = require('../lib/type-registry'); var expect = require('chai').expect; var _defaults = require('lodash').defaults; describe('route-helper', function() { it('returns "object" when a route has multiple return values', function() { - var doc = createAPIDoc({ + var entry = createAPIDoc({ returns: [ { arg: 'max', type: 'number' }, { arg: 'min', type: 'number' }, { arg: 'avg', type: 'number' } ] }); - expect(doc.operations[0].type).to.equal('object'); - expect(getResponseType(doc.operations[0])).to.equal('object'); + // TODO use a custom (dynamicaly-created) model schema instead of "object" + expect(getResponseMessage(entry.operation)) + .to.have.property('schema').eql({ type: 'object' }); }); it('converts path params when they exist in the route name', function() { - var doc = createAPIDoc({ + var entry = createAPIDoc({ accepts: [ {arg: 'id', type: 'string'} ], path: '/test/:id' }); - var paramDoc = doc.operations[0].parameters[0]; - expect(paramDoc.paramType).to.equal('path'); - expect(paramDoc.name).to.equal('id'); - expect(paramDoc.required).to.equal(false); + var paramDoc = entry.operation.parameters[0]; + expect(paramDoc).to.have.property('in', 'path'); + expect(paramDoc).to.have.property('name', 'id'); + expect(paramDoc).to.have.property('required', false); }); // FIXME need regex in routeHelper.acceptToParameter @@ -38,8 +40,8 @@ describe('route-helper', function() { ], path: '/test/:identifier' }); - var paramDoc = doc.operations[0].parameters[0]; - expect(paramDoc.paramType).to.equal('query'); + var paramDoc = doc.operation.parameters[0]; + expect(paramDoc.in).to.equal('query'); }); it('correctly coerces param types', function() { @@ -48,71 +50,77 @@ describe('route-helper', function() { {arg: 'binaryData', type: 'buffer'} ] }); - var paramDoc = doc.operations[0].parameters[0]; - expect(paramDoc.paramType).to.equal('query'); - expect(paramDoc.type).to.equal('string'); - expect(paramDoc.format).to.equal('byte'); + var paramDoc = doc.operation.parameters[0]; + expect(paramDoc).to.have.property('in', 'query'); + expect(paramDoc).to.have.property('type', 'string'); + expect(paramDoc).to.have.property('format', 'byte'); }); it('correctly converts return types (arrays)', function() { var doc = createAPIDoc({ returns: [ - {arg: 'data', type: ['customType']} + { arg: 'data', type: ['customType'], root: true } ] }); - var opDoc = doc.operations[0]; - expect(getResponseType(opDoc)).to.eql('[customType]'); + var opDoc = doc.operation; - // 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'}); + var responseSchema = getResponseMessage(opDoc).schema; + expect(responseSchema).to.have.property('type', 'array'); + expect(responseSchema).to.have.property('items') + .eql({ $ref: '#/definitions/customType' }); }); it('correctly converts return types (format)', function() { var doc = createAPIDoc({ - returns: [ - {arg: 'data', type: 'buffer'} - ] - }); - var opDoc = doc.operations[0]; - expect(opDoc.type).to.equal('string'); - expect(opDoc.format).to.equal('byte'); + returns: [ + { arg: 'data', type: 'buffer', root: true } + ] + }); + + var responseSchema = getResponseMessage(doc.operation).schema; + expect(responseSchema.type).to.equal('string'); + expect(responseSchema.format).to.equal('byte'); }); - it('includes `notes` metadata', function() { + it('includes `notes` metadata as `description`', function() { var doc = createAPIDoc({ notes: 'some notes' }); - expect(doc.operations[0].notes).to.equal('some notes'); + expect(doc.operation).to.have.property('description', '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("1\n2\n3"); + describe('#acceptToParameter', function() { + var A_CLASS_DEF = { name: 'TestModelName' }; + + it('returns fn converting description from array to string', function() { + var f = routeHelper.acceptToParameter( + {verb: 'get', path: 'path'}, + A_CLASS_DEF, + new TypeRegistry()); + var result = f({description: ['1', '2', '3']}); + expect(result.description).to.eql('1\n2\n3'); }); }); - describe('#routeToAPIDoc', function() { - it('should convert route.description from array of string to string', function () { - var result = routeHelper.routeToAPIDoc({ + describe('#routeToPathEntry', function() { + it('converts route.description from array to string', function() { + var result = routeHelper.routeToPathEntry({ method: 'someMethod', verb: 'get', path: 'path', description: ['1', '2', '3'] }); - expect(result.operations[0].summary).to.eql("1\n2\n3"); + expect(result.operation.summary).to.eql('1\n2\n3'); }); - it('should convert route.notes from array of string to string', function () { - var result = routeHelper.routeToAPIDoc({ + it('converts route.notes from array of string to string', function() { + var result = routeHelper.routeToPathEntry({ method: 'someMethod', verb: 'get', path: 'path', notes: ['1', '2', '3'] }); - expect(result.operations[0].notes).to.eql("1\n2\n3"); + expect(result.operation.description).to.eql("1\n2\n3"); }); }); @@ -120,28 +128,28 @@ describe('route-helper', function() { var doc = createAPIDoc({ deprecated: 'true' }); - expect(doc.operations[0].deprecated).to.equal('true'); + expect(doc.operation).to.have.property('deprecated', true); }); it('joins array description/summary', function() { var doc = createAPIDoc({ description: [ 'line1', 'line2' ] }); - expect(doc.operations[0].summary).to.equal('line1\nline2'); + expect(doc.operation.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'); + expect(doc.operation.description).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'); + expect(doc.operation.parameters[0].description).to.equal('line1\nline2'); }); it('correctly does not include context params', function() { @@ -151,7 +159,7 @@ describe('route-helper', function() { ], path: '/test' }); - var params = doc.operations[0].parameters; + var params = doc.operation.parameters; expect(params.length).to.equal(0); }); @@ -162,7 +170,7 @@ describe('route-helper', function() { ], path: '/test' }); - var params = doc.operations[0].parameters; + var params = doc.operation.parameters; expect(params.length).to.equal(0); }); @@ -173,7 +181,7 @@ describe('route-helper', function() { ], path: '/test' }); - var params = doc.operations[0].parameters; + var params = doc.operation.parameters; expect(params.length).to.equal(0); }); @@ -181,7 +189,7 @@ describe('route-helper', function() { var doc = createAPIDoc({ accepts: [{ name: 'arg', type: 'number', enum: [1,2,3] }] }); - expect(doc.operations[0].parameters[0]) + expect(doc.operation.parameters[0]) .to.have.property('enum').eql([1,2,3]); }); @@ -189,28 +197,24 @@ describe('route-helper', 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', - responseModel: 'object' + expect(doc.operation.responses).to.eql({ + 200: { + description: 'Request was successful', + schema: { type: 'object' } } - ]); + }); }); 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', - responseModel: 'void' + expect(doc.operation.responses).to.eql({ + 204: { + description: 'Request was successful', + schema: undefined } - ]); + }); }); it('includes custom error response in `responseMessages`', function() { @@ -221,36 +225,56 @@ describe('route-helper', function() { responseModel: 'ValidationError' }] }); - expect(doc.operations[0].responseMessages[1]).to.eql({ - code: 422, - message: 'Validation failed', - responseModel: 'ValidationError' + expect(doc.operation.responses).to.have.property(422).eql({ + description: 'Validation failed', + schema: { $ref: '#/definitions/ValidationError' } }); }); - it('route nickname does not include model name.', function() { - var doc = createAPIDoc(); - expect(doc.operations[0].nickname).to.equal('get'); + it('route operationId DOES include model name.', function() { + var doc = createAPIDoc({ method: 'User.login' }); + expect(doc.operation.operationId).to.equal('User.login'); }); - it('route nickname with a period is shorted correctly', function() { - // Method is built by remoting to always be #{className}.#{methodName} + it('adds class name to `tags`', function() { + var doc = createAPIDoc( + { method: 'User.login' }, + { name: 'User' }); + expect(doc.operation.tags).to.contain('User'); + }); + + it('converts non-primitive param types to JSON strings', function() { var doc = createAPIDoc({ - method: 'test.get.me' + accepts: [{arg: 'filter', type: 'object', http: { source: 'query' }}] }); - expect(doc.operations[0].nickname).to.eql('get.me'); + var param = doc.operation.parameters[0]; + expect(param).to.have.property('type', 'string'); + expect(param).to.have.property('format', 'JSON'); + }); + + it('converts single "data" body arg to Model type', function() { + var doc = createAPIDoc( + { + accepts: [{arg: 'data', type: 'object', http: { source: 'body' }}], + }, + { name: 'User' }); + var param = doc.operation.parameters[0]; + expect(param) + .to.have.property('schema') + .eql({ $ref: '#/definitions/User' }); }); }); // Easy wrapper around createRoute -function createAPIDoc(def) { - return routeHelper.routeToAPIDoc(_defaults(def || {}, { +function createAPIDoc(def, classDef) { + return routeHelper.routeToPathEntry(_defaults(def || {}, { path: '/test', verb: 'GET', method: 'test.get' - })); + }), classDef, new TypeRegistry()); } -function getResponseType(operationDoc) { - return operationDoc.responseMessages[0].responseModel; +function getResponseMessage(operationDoc) { + return operationDoc.responses[200] || operationDoc.responses[204] + || operationDoc.responses.default; } diff --git a/test/schema-builder.test.js b/test/schema-builder.test.js new file mode 100644 index 0000000..7084ae0 --- /dev/null +++ b/test/schema-builder.test.js @@ -0,0 +1,100 @@ +'use strict'; + +var schemaBuilder = require('../lib/schema-builder'); +var TypeRegistry = require('../lib/type-registry'); +var format = require('util').format; +var _defaults = require('lodash').defaults; +var loopback = require('loopback'); +var expect = require('chai').expect; + +var ANY_TYPE = { $ref: '#/definitions/x-any' }; + +describe('schema-builder', function() { + describeTestCases('for constructor types', [ + { in: String, out: { type: 'string' } }, + { in: Number, out: { type: 'number', format: 'double' } }, + { in: Date, out: { type: 'string', format: 'date' } }, + { in: Boolean, out: { type: 'boolean' } }, + { in: Buffer, out: { type: 'string', format: 'byte' } } + ]); + + describeTestCases('for string types', [ + { in: 'string', out: { type: 'string' } }, + { in: 'number', out: { type: 'number', format: 'double' } }, + { in: 'date', out: { type: 'string', format: 'date' } }, + { in: 'boolean', out: { type: 'boolean' } }, + { in: 'buffer', out: { type: 'string', format: 'byte' } }, + ]); + + describeTestCases('for array definitions', [ + { in: [String], + out: { type: 'array', items: { type: 'string' } } }, + { in: ['string'], + out: { type: 'array', items: { type: 'string' } } }, + { in: [{ type: 'string', maxLength: 64 }], + out: { type: 'array', items: { type: 'string', maxLength: 64 } } }, + { in: [{ type: 'date' }], + out: { type: 'array', items: { type: 'string', format: 'date' } } }, + { in: [], + out: { type: 'array', items: ANY_TYPE } }, + // This value is somehow provided by loopback-boot called from + // loopback-workspace. + { in: [undefined], + out: { type: 'array', items: ANY_TYPE } }, + { in: 'array', + out: { type: 'array', items: ANY_TYPE } }, + ]); + + describeTestCases('for complex types', [ + // Note: User is a built-in loopback model + { in: loopback.User, + out: { $ref: '#/definitions/User' } }, + { in: { type: 'User' }, + out: { $ref: '#/definitions/User' } }, + // Anonymous type + { in: { type: { foo: 'string', bar: 'number' } }, + out: { type: 'object' } }, + ]); + + describeTestCases('for extra metadata', [ + { in: { type: String, doc: 'a-description' }, + out: { type: 'string', description: 'a-description' } }, + { in: { type: String, doc: ['line1', 'line2'] }, + out: { type: 'string', description: 'line1\nline2' } }, + { in: { type: String, description: 'a-description' }, + out: { type: 'string', description: 'a-description' } }, + { in: { type: String, description: ['line1', 'line2'] }, + out: { type: 'string', description: 'line1\nline2' } }, + { in: { type: String, required: true }, + out: { type: 'string' } }, // the flag required is handled specially + { in: { type: String, length: 10 }, + out: { type: 'string', maxLength: 10 } }, + ]); + + function describeTestCases(name, testCases) { + describe(name, function() { + testCases.forEach(function(tc) { + var inStr = formatType(tc.in); + var outStr = formatType(tc.out); + it(format('converts %s to %s', inStr, outStr), function() { + var registry = new TypeRegistry(); + var schema = schemaBuilder.buildFromLoopBackType(tc.in, registry); + expect(schema).to.eql(tc.out); + }); + }); + }); + } + + function formatType(type) { + if (Array.isArray(type)) + return '[' + type.map(formatType) + ']'; + + if (typeof type === 'function') + return type.modelName ? + 'model ' + type.modelName : + 'ctor ' + type.name; + + return format(type); + } + +}); diff --git a/test/swagger.test.js b/test/swagger.test.js index 063ebf7..3354d04 100644 --- a/test/swagger.test.js +++ b/test/swagger.test.js @@ -9,220 +9,180 @@ var request = require('supertest'); var expect = require('chai').expect; describe('swagger definition', function() { + describe('defaults', function() { + var swaggerResource; + before(function() { + var app = createLoopbackAppWithModel(); + swaggerResource = swagger.createSwaggerObject(app); + }); + + it('advertises Swagger Spec version 2.0', function() { + expect(swaggerResource).to.have.property('swagger', '2.0'); + }); + + it('has "basePath" set to "/api"', function() { + expect(swaggerResource).to.have.property('basePath', '/api'); + }); + + it('uses the "host" serving the documentation', function() { + // see swagger-spec/2.0.md#fixed-fields + // If the host is not included, the host serving the documentation is to + // be used (including the port). + expect(swaggerResource).to.have.property('host', undefined); + }); + + it('uses the "schemes" serving the documentation', function() { + // see swagger-spec/2.0.md#fixed-fields + // If the schemes is not included, the default scheme to be used is the + // one used to access the Swagger definition itself. + expect(swaggerResource).to.have.property('schemes', undefined); + }); + + it('provides info.title', function() { + expect(swaggerResource.info) + .to.have.property('title', 'loopback-explorer'); + }); + }); + describe('basePath', function() { - // No basepath on resource doc in 1.2 - it('no longer exists on resource doc', function(done) { - var app = givenAppWithSwagger(); - - var getReq = getSwaggerResources(app); - getReq.end(function(err, res) { - if (err) return done(err); - expect(res.body.basePath).to.equal(undefined); - done(); + it('is "{basePath}" when basePath is a path', function() { + var app = createLoopbackAppWithModel(); + var swaggerResource = swagger.createSwaggerObject(app, { + basePath: '/api-root' }); + + expect(swaggerResource.basePath).to.equal('/api-root'); }); - it('is "http://{host}/api" by default', function(done) { - var app = givenAppWithSwagger(); - - var getReq = getAPIDeclaration(app, 'products'); - getReq.end(function(err, res) { - if (err) return done(err); - expect(res.body.basePath).to.equal(url.resolve(getReq.url, '/api')); - done(); - }); + it('is inferred from app.get("apiRoot")', function() { + var app = createLoopbackAppWithModel(); + app.set('restApiRoot', '/custom-api-root'); + var swaggerResource = swagger.createSwaggerObject(app); + expect(swaggerResource.basePath).to.equal('/custom-api-root'); }); - it('is "http://{host}/{basePath}" when basePath is a path', function(done){ - var app = givenAppWithSwagger({ basePath: '/api-root'}); - - var getReq = getAPIDeclaration(app, 'products'); - getReq.end(function(err, res) { - if (err) return done(err); - var apiRoot = url.resolve(getReq.url, '/api-root'); - expect(res.body.basePath).to.equal(apiRoot); - done(); - }); - }); - - it('infers API basePath from app', function(done){ - var app = givenAppWithSwagger({}, {apiRoot: '/custom-api-root'}); - - var getReq = getAPIDeclaration(app, 'products'); - getReq.end(function(err, res) { - if (err) return done(err); - var apiRoot = url.resolve(getReq.url, '/custom-api-root'); - expect(res.body.basePath).to.equal(apiRoot); - done(); - }); - }); - - it('is reachable when explorer mounting location is changed', function(done){ + it('is reachable when explorer mounting location is changed', + function(done) { var explorerRoot = '/erforscher'; var app = givenAppWithSwagger({}, {explorerRoot: explorerRoot}); - var getReq = getSwaggerResources(app, explorerRoot, 'products'); - getReq.end(function(err, res) { + getSwaggerResource(app, explorerRoot).end(function(err, res) { if (err) return done(err); expect(res.body.basePath).to.be.a('string'); done(); }); }); - it('respects a hardcoded protocol (behind SSL terminator)', function(done){ - var app = givenAppWithSwagger({protocol: 'https'}); - - var getReq = getAPIDeclaration(app, 'products'); - getReq.end(function(err, res) { - if (err) return done(err); - var parsed = url.parse(res.body.basePath); - expect(parsed.protocol).to.equal('https:'); - done(); + it('respects a hardcoded protocol (behind SSL terminator)', function() { + var app = createLoopbackAppWithModel(); + var swaggerResource = swagger.createSwaggerObject(app, { + protocol: 'https' }); + expect(swaggerResource.schemes).to.eql(['https']); }); - 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(); - }); - }); - - it('supports options.omitProtocolInBaseUrl', function(done) { - var app = givenAppWithSwagger({ omitProtocolInBaseUrl: true }); - - var getReq = getAPIDeclaration(app, 'products'); - getReq.end(function(err, res) { - if (err) return done(err); - var basePath = res.body.basePath; - expect(basePath).to.match(/^\/\//); - var parsed = url.parse(res.body.basePath); - expect(parsed.protocol).to.equal(null); - done(); + it('supports opts.host', function() { + var app = createLoopbackAppWithModel(); + var swaggerResource = swagger.createSwaggerObject(app, { + host: 'example.com:8080' }); - }); - - it('supports opts.header', function(done) { - var app = givenAppWithSwagger({ host: 'example.com:8080' }); - getAPIDeclaration(app, 'products') - .end(function(err, res) { - if (err) return done(err); - var baseUrl = url.parse(res.body.basePath); - expect(baseUrl.host).to.equal('example.com:8080'); - done(); - }); + expect(swaggerResource.host).to.equal('example.com:8080'); }); }); - describe('Model definition attributes', function() { - it('Properly defines basic attributes', function(done) { - var app = givenAppWithSwagger(); + it('has global "consumes"', function() { + var app = createLoopbackAppWithModel(); + var swaggerResource = swagger.createSwaggerObject(app); + expect(swaggerResource.consumes).to.have.members([ + 'application/json', + 'application/x-www-form-urlencoded', + 'application/xml', 'text/xml' + ]); + }); - var getReq = getAPIDeclaration(app, 'products'); - getReq.end(function(err, res) { - if (err) return done(err); - var data = res.body.models.product; - expect(data.id).to.equal('product'); - expect(data.required.sort()).to.eql(['aNum', 'foo'].sort()); - expect(data.properties.foo.type).to.equal('string'); - expect(data.properties.bar.type).to.equal('string'); - expect(data.properties.aNum.type).to.equal('number'); - // These will be Numbers for Swagger 2.0 - expect(data.properties.aNum.minimum).to.equal('1'); - expect(data.properties.aNum.maximum).to.equal('10'); - // Should be Number even in 1.2 - expect(data.properties.aNum.defaultValue).to.equal(5); - done(); - }); + it('has global "produces"', function() { + var app = createLoopbackAppWithModel(); + var swaggerResource = swagger.createSwaggerObject(app); + expect(swaggerResource.produces).to.have.members([ + 'application/json', + 'application/xml', 'text/xml', + // JSONP content types + 'application/javascript', 'text/javascript' + ]); + }); + + describe('tags', function() { + it('has one tag for each model', function() { + var app = createLoopbackAppWithModel(); + var swaggerResource = swagger.createSwaggerObject(app); + expect(swaggerResource.tags).to.eql([ + { name: 'Product', description: 'a-description\nline2' } + ]); + }); + }); + + describe('paths node', function() { + it('contains model routes for static methods', function() { + var app = createLoopbackAppWithModel(); + var swaggerResource = swagger.createSwaggerObject(app); + expect(swaggerResource.paths).to.have.property('/Products'); + var products = swaggerResource.paths['/Products']; + var verbs = Object.keys(products); + verbs.sort(); + expect(verbs).to.eql(['get', 'post', 'put']); + }); + }); + + describe('definitions node', function() { + it('properly defines basic attributes', function() { + var app = createLoopbackAppWithModel(); + var swaggerResource = swagger.createSwaggerObject(app); + var data = swaggerResource.definitions.Product; + expect(data.required.sort()).to.eql(['aNum', 'foo'].sort()); + expect(data.properties.foo.type).to.equal('string'); + expect(data.properties.bar.type).to.equal('string'); + expect(data.properties.aNum.type).to.equal('number'); + // These will be Numbers for Swagger 2.0 + expect(data.properties.aNum.minimum).to.equal(1); + expect(data.properties.aNum.maximum).to.equal(10); + // Should be Number even in 1.2 + expect(data.properties.aNum.default).to.equal(5); }); - 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) { + it('includes models from "accepts" args', function() { 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(); - }); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)).to.include('Image'); }); - it('includes models from `returns` args', function(done) { + it('includes models from "returns" args', function() { var app = createLoopbackAppWithModel(); givenPrivateAppModel(app, 'Image'); givenSharedMethod(app.models.Product, 'getImage', { - returns: { name: 'image', type: 'Image' } + returns: { name: 'image', type: 'Image', root: true } }); - mountExplorer(app); - getAPIDeclaration(app, 'products').end(function(err, res) { - expect(Object.keys(res.body.models)).to.include('Image'); - done(); - }); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)).to.include('Image'); }); - it('includes `accepts` models not attached to the app', function(done) { + it('includes "accepts" models not attached to the app', function() { 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(); - }); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)).to.include('Image'); }); - it('includes `responseMessages` models', function(done) { + it('includes "responseMessages" models', function() { var app = createLoopbackAppWithModel(); loopback.createModel('ValidationError'); givenSharedMethod(app.models.Product, 'setImage', { @@ -233,37 +193,45 @@ describe('swagger definition', function() { }] }); - expectProductDocIncludesModels(app, 'ValidationError', done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include('ValidationError'); }); - it('includes nested model references in properties', function(done) { + it('includes nested model references in properties', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); app.models.Product.defineProperty('location', { type: 'Warehouse' }); - expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse']); }); - it('includes nested array model references in properties', function(done) { + it('includes nested array model references in properties', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); app.models.Product.defineProperty('location', { type: ['Warehouse'] }); - expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse']); }); - it('includes nested model references in modelTo relation', function(done) { + it('includes nested model references in modelTo relation', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); app.models.Product.belongsTo(app.models.Warehouse); - expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse']); }); - it('includes nested model references in modelTo relation', function(done) { + it('includes nested model references in modelThrough relation', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); givenPrivateAppModel(app, 'ProductLocations'); @@ -271,13 +239,12 @@ describe('swagger definition', function() { app.models.Product.hasMany(app.models.Warehouse, { through: app.models.ProductLocations }); - expectProductDocIncludesModels( - app, - ['Address', 'Warehouse', 'ProductLocations'], - done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse', 'ProductLocations']); }); - it('includes nested model references in accept args', function(done) { + it('includes nested model references in accept args', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); @@ -285,21 +252,25 @@ describe('swagger definition', function() { accepts: { arg: 'w', type: 'Warehouse' } }); - expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse']); }); - it('includes nested array model references in accept args', function(done) { + it('includes nested array model references in accept args', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); givenSharedMethod(app.models.Product, 'aMethod', { - accepts: { arg: 'w', type: [ 'Warehouse' ] } + accepts: { arg: 'w', type: ['Warehouse'] } }); - expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse']); }); - it('includes nested model references in return args', function(done) { + it('includes nested model references in return args', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); @@ -307,10 +278,12 @@ describe('swagger definition', function() { returns: { arg: 'w', type: 'Warehouse', root: true } }); - expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse']); }); - it('includes nested array model references in return args', function(done) { + it('includes nested array model references in return args', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); @@ -318,10 +291,12 @@ describe('swagger definition', function() { returns: { arg: 'w', type: ['Warehouse'], root: true } }); - expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse']); }); - it('includes nested model references in error responses', function(done) { + it('includes nested model references in error responses', function() { var app = createLoopbackAppWithModel(); givenWarehouseWithAddressModels(app); @@ -333,7 +308,9 @@ describe('swagger definition', function() { } }); - expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + var swaggerResource = swagger.createSwaggerObject(app); + expect(Object.keys(swaggerResource.definitions)) + .to.include.members(['Address', 'Warehouse']); }); }); @@ -341,7 +318,7 @@ describe('swagger definition', function() { it('allows cross-origin requests by default', function(done) { var app = givenAppWithSwagger(); request(app) - .options('/explorer/resources') + .options('/explorer/swagger.json') .set('Origin', 'http://example.com/') .expect('Access-Control-Allow-Origin', /^http:\/\/example.com\/|\*/) .expect('Access-Control-Allow-Methods', /\bGET\b/) @@ -349,9 +326,11 @@ describe('swagger definition', function() { }); it('can be disabled by configuration', function(done) { - var app = givenAppWithSwagger({}, { remoting: { cors: { origin: false } } }); + var app = givenAppWithSwagger({}, { + remoting: { cors: { origin: false } } + }); request(app) - .options('/explorer/resources') + .options('/explorer/swagger.json') .end(function(err, res) { if (err) return done(err); var allowOrigin = res.get('Access-Control-Allow-Origin'); @@ -362,16 +341,17 @@ describe('swagger definition', function() { }); }); - function getSwaggerResources(app, restPath, classPath) { + function getSwaggerResource(app, restPath, classPath) { + if (classPath) throw new Error('classPath is no longer supported'); return request(app) - .get(urlJoin(restPath || '/explorer', '/resources', classPath || '')) + .get(urlJoin(restPath || '/explorer', '/swagger.json')) .set('Accept', 'application/json') .expect(200) .expect('Content-Type', /json/); } function getAPIDeclaration(app, className) { - return getSwaggerResources(app, '', urlJoin('/', className)); + return getSwaggerResource(app, '', urlJoin('/', className)); } function givenAppWithSwagger(swaggerOptions, appConfig) { @@ -387,7 +367,7 @@ describe('swagger definition', function() { function mountExplorer(app, options) { var swaggerApp = loopback(); - swagger(app, swaggerApp, options); + swagger.mountSwagger(app, swaggerApp, options); app.use(app.get('explorerRoot') || '/explorer', swaggerApp); return app; } @@ -397,12 +377,12 @@ describe('swagger definition', function() { app.dataSource('db', { connector: 'memory' }); - var Product = loopback.Model.extend('product', { + var Product = loopback.createModel('Product', { foo: {type: 'string', required: true}, bar: 'string', aNum: {type: 'number', min: 1, max: 10, required: true, default: 5} - }); - app.model(Product, { dataSource: 'db'}); + }, { description: ['a-description', 'line2'] }); + app.model(Product, { dataSource: 'db' }); // Simulate a restApiRoot set in config app.set('restApiRoot', apiRoot || '/api'); @@ -412,13 +392,13 @@ describe('swagger definition', function() { } function givenSharedMethod(model, name, metadata) { - model[name] = function(){}; + model[name] = function() {}; loopback.remoteMethod(model[name], metadata); } function givenPrivateAppModel(app, name, properties) { var model = loopback.createModel(name, properties); - app.model(model, { dataSource: 'db', public: false} ); + app.model(model, { dataSource: 'db', public: false }); } function givenWarehouseWithAddressModels(app) { @@ -427,16 +407,4 @@ describe('swagger definition', function() { 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(); - }); - } }); diff --git a/test/tag-builder.test.js b/test/tag-builder.test.js new file mode 100644 index 0000000..c0b69c0 --- /dev/null +++ b/test/tag-builder.test.js @@ -0,0 +1,23 @@ +'use strict'; + +var tagBuilder = require('../lib/tag-builder'); +var expect = require('chai').expect; +var _defaults = require('lodash').defaults; + +describe('tag-builder', function() { + it('joins array descriptions from ctor.settings', function() { + var tag = tagBuilder.buildTagFromClass({ + ctor: { settings: { description: ['line1', 'line2'] } } + }); + + expect(tag.description).to.equal('line1\nline2'); + }); + + it('joins array descriptions from ctor.sharedCtor', function() { + var tag = tagBuilder.buildTagFromClass({ + ctor: { sharedCtor: { description: ['1', '2', '3'] } } + }); + + expect(tag.description).to.eql('1\n2\n3'); + }); +});