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.
This commit is contained in:
Miroslav Bajtoš 2015-08-13 17:20:11 +02:00
parent ee42f0386c
commit 0b17811546
17 changed files with 984 additions and 1264 deletions

View File

@ -35,13 +35,13 @@ function routes(loopbackApplication, options) {
} }
options = _defaults({}, options, { options = _defaults({}, options, {
resourcePath: 'resources', resourcePath: 'swagger.json',
apiInfo: loopbackApplication.get('apiInfo') || {} apiInfo: loopbackApplication.get('apiInfo') || {}
}); });
var router = new loopback.Router(); 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 // config.json is loaded by swagger-ui. The server should respond
// with the relative URI of the resource doc. // 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 // File in node_modules are overridden by a few customizations
router.use(loopback.static(STATIC_ROOT)); router.use(loopback.static(STATIC_ROOT));

View File

@ -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)
};
}
};

View File

@ -3,11 +3,9 @@
/** /**
* Module dependencies. * Module dependencies.
*/ */
var _cloneDeep = require('lodash').cloneDeep; var schemaBuilder = require('./schema-builder');
var _pick = require('lodash').pick;
var translateDataTypeKeys = require('./translate-data-type-keys');
var TYPES_PRIMITIVE = ['array', 'boolean', 'integer', 'number', 'null', 'object', 'string', 'any'];
var typeConverter = require('./type-converter'); var typeConverter = require('./type-converter');
var TypeRegistry = require('./type-registry');
/** /**
* Export the modelHelper singleton. * Export the modelHelper singleton.
@ -17,205 +15,103 @@ var modelHelper = module.exports = {
* Given a class (from remotes.classes()), generate a model definition. * Given a class (from remotes.classes()), generate a model definition.
* This is used to generate the schema at the top of many endpoints. * This is used to generate the schema at the top of many endpoints.
* @param {Class} modelClass Model class. * @param {Class} modelClass Model class.
* @param {Object} definitions Model definitions * @param {TypeRegistry} typeRegistry Registry of types and models.
* @return {Object} Associated model definition. * @return {Object} Associated model definition.
*/ */
generateModelDefinition: function generateModelDefinition(modelClass, definitions) { registerModelDefinition: function(modelCtor, typeRegistry) {
var processType = function(app, modelName, referencedModels) { var lbdef = modelCtor.definition;
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);
}
}
};
var convertTypeTo$Ref = function convertTypeTo$Ref(prop){ if (!lbdef) {
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) {
// The model does not have any definition, it was most likely // The model does not have any definition, it was most likely
// created as a placeholder for an unknown property type // created as a placeholder for an unknown property type
return out; return;
} }
var name = def.name; var name = lbdef.name;
if (out[name]) { if (typeRegistry.isDefined(name)) {
// The model is already included // The model is already included
return out; return;
} }
var required = [];
// Don't modify original properties.
var properties = _cloneDeep(def.rawProperties || def.properties);
var referencedModels = []; var swaggerDef = {
// Add models from settings description: typeConverter.convertText(
if (def.settings && def.settings.models) { lbdef.description || (lbdef.settings && lbdef.settings.description)),
for (var m in def.settings.models) { properties: {},
var model = modelClass[m]; required: []
if (typeof model === 'function' && model.modelName) { };
if (model && referencedModels.indexOf(model) === -1) {
referencedModels.push(model); var properties = lbdef.rawProperties || lbdef.properties;
}
}
}
}
// Iterate through each property in the model definition. // Iterate through each property in the model definition.
// Types may be defined as constructors (e.g. String, Date, etc.), // Types may be defined as constructors (e.g. String, Date, etc.),
// or as strings; getPropType() will take care of the conversion. // or as strings; swaggerSchema.builFromLoopBackType() will take
// See more on types: // care of the conversion.
// https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#431-primitives
Object.keys(properties).forEach(function(key) { Object.keys(properties).forEach(function(key) {
var prop = properties[key]; var prop = properties[key];
// Hide hidden properties. // Hide hidden properties.
if (modelHelper.isHiddenProperty(def, key)) { if (modelHelper.isHiddenProperty(lbdef, key))
delete properties[key];
return; return;
}
// Eke a type out of the constructors we were passed. // Eke a type out of the constructors we were passed.
var swaggerType = modelHelper.LDLPropToSwaggerDataType(prop); var schema = schemaBuilder.buildFromLoopBackType(prop, typeRegistry);
processType(modelClass.app, swaggerType.type, referencedModels);
convertTypeTo$Ref(swaggerType);
if (swaggerType.items) {
processType(modelClass.app, swaggerType.items.type, referencedModels);
convertTypeTo$Ref(swaggerType.items);
}
var desc = typeConverter.convertText(prop.description || prop.doc); 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. // Required props sit in a per-model array.
if (prop.required || (prop.id && !prop.generated)) { if (prop.required || (prop.id && !prop.generated)) {
required.push(key); swaggerDef.required.push(key);
} }
// Change mismatched keys. // Assign the schema to the properties object.
prop = translateDataTypeKeys(prop); swaggerDef.properties[key] = schema;
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);
}
}
}
}); });
var additionalProperties = undefined; if (lbdef.settings) {
if (def.settings){ var strict = lbdef.settings.strict;
var strict = def.settings.strict; var additionalProperties = lbdef.settings.additionalProperties;
additionalProperties = def.settings.additionalProperties;
var notAllowAdditionalProperties = strict || (additionalProperties !== true); var notAllowAdditionalProperties = strict || (additionalProperties !== true);
if (notAllowAdditionalProperties){ if (notAllowAdditionalProperties){
additionalProperties = false; swaggerDef.additionalProperties = false;
} }
} }
out[name] = { if (!swaggerDef.required.length) {
id: name, // "required" must have at least one item when present
additionalProperties: additionalProperties, delete swaggerDef.required;
description: typeConverter.convertText( }
def.description || (def.settings && def.settings.description)),
properties: properties,
required: required
};
if (def.description){ typeRegistry.register(name, swaggerDef);
out[name].description = typeConverter.convertText(def.description);
// 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 // Generate model definitions for related models
for (var r in modelClass.relations) { for (var r in modelCtor.relations) {
var rel = modelClass.relations[r]; var rel = modelCtor.relations[r];
if (rel.modelTo && referencedModels.indexOf(rel.modelTo) === -1) { if (rel.modelTo) {
referencedModels.push(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) { if (rel.modelThrough) {
referencedModels.push(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) { isHiddenProperty: function(definition, propName) {
@ -223,67 +119,4 @@ var modelHelper = module.exports = {
Array.isArray(definition.settings.hidden) && Array.isArray(definition.settings.hidden) &&
definition.settings.hidden.indexOf(propName) !== -1; 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;
}
}; };

View File

@ -5,10 +5,9 @@
*/ */
var debug = require('debug')('loopback:explorer:routeHelpers'); var debug = require('debug')('loopback:explorer:routeHelpers');
var _cloneDeep = require('lodash').cloneDeep;
var _assign = require('lodash').assign; var _assign = require('lodash').assign;
var modelHelper = require('./model-helper');
var typeConverter = require('./type-converter'); var typeConverter = require('./type-converter');
var schemaBuilder = require('./schema-builder');
/** /**
* Export the routeHelper singleton. * Export the routeHelper singleton.
@ -17,7 +16,7 @@ var routeHelper = module.exports = {
/** /**
* Given a route, generate an API description and add it to the doc. * 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), * 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', * Routes can be translated to API declaration 'operations',
* but they need a little massaging first. The `accepts` and * but they need a little massaging first. The `accepts` and
@ -26,29 +25,29 @@ var routeHelper = module.exports = {
* This method will convert the route and add it to the doc. * This method will convert the route and add it to the doc.
* @param {Route} route Strong Remoting Route object. * @param {Route} route Strong Remoting Route object.
* @param {Class} classDef Strong Remoting class. * @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) { addRouteToSwaggerPaths: function(route, classDef, typeRegistry, paths) {
var api = routeHelper.routeToAPIDoc(route, classDef); var entryToAdd = routeHelper.routeToPathEntry(route, classDef,
var matchingAPIs = doc.apis.filter(function(existingAPI) { typeRegistry);
return existingAPI.path === api.path; if (!(entryToAdd.path in paths)) {
}); paths[entryToAdd.path] = {};
if (matchingAPIs.length) {
matchingAPIs[0].operations.push(api.operations[0]);
} else {
doc.apis.push(api);
} }
paths[entryToAdd.path][entryToAdd.method] = entryToAdd.operation;
}, },
/** /**
* Massage route.accepts. * Massage route.accepts.
* @param {Object} route Strong Remoting Route object. * @param {Object} route Strong Remoting Route object.
* @param {Class} classDef Strong Remoting class. * @param {Class} classDef Strong Remoting class.
* @param {TypeRegistry} typeRegistry Registry of types and models.
* @return {Array} Array of param docs. * @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 split = route.method.split('.');
var accepts = _cloneDeep(route.accepts) || [];
if (classDef && classDef.sharedCtor && if (classDef && classDef.sharedCtor &&
classDef.sharedCtor.accepts && split.length > 2 /* HACK */) { classDef.sharedCtor.accepts && split.length > 2 /* HACK */) {
accepts = accepts.concat(classDef.sharedCtor.accepts); accepts = accepts.concat(classDef.sharedCtor.accepts);
@ -56,7 +55,7 @@ var routeHelper = module.exports = {
// Filter out parameters that are generated from the incoming request, // Filter out parameters that are generated from the incoming request,
// or generated by functions that use those resources. // or generated by functions that use those resources.
accepts = accepts.filter(function(arg){ accepts = accepts.filter(function(arg) {
if (!arg.http) return true; if (!arg.http) return true;
// Don't show derived arguments. // Don't show derived arguments.
if (typeof arg.http === 'function') return false; if (typeof arg.http === 'function') return false;
@ -72,7 +71,8 @@ var routeHelper = module.exports = {
}); });
// Turn accept definitions in to parameter docs. // Turn accept definitions in to parameter docs.
accepts = accepts.map(routeHelper.acceptToParameter(route)); accepts = accepts.map(
routeHelper.acceptToParameter(route, classDef, typeRegistry));
return accepts; return accepts;
}, },
@ -80,136 +80,94 @@ var routeHelper = module.exports = {
/** /**
* Massage route.returns. * Massage route.returns.
* @param {Object} route Strong Remoting Route object. * @param {Object} route Strong Remoting Route object.
* @param {Class} classDef Strong Remoting class.
* @return {Object} A single returns param doc. * @return {Object} A single returns param doc.
*/ */
convertReturnsToSwagger: function convertReturnsToSwagger(route, classDef) { convertReturnsToSwagger: function(route, typeRegistry) {
var routeReturns = _cloneDeep(route.returns) || []; var routeReturns = route.returns;
// HACK: makes autogenerated REST routes return the correct model name. if (!routeReturns || !routeReturns.length) {
var firstReturn = routeReturns && routeReturns[0]; // An operation that returns nothing will have
if (firstReturn && firstReturn.arg === 'data') { // no schema declaration for its response.
if (firstReturn.type === 'object') { return undefined;
firstReturn.type = classDef.name;
} else if (firstReturn.type === 'array') {
firstReturn.type = [classDef.name];
} }
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 // Convert `returns` into a single object for later conversion into an
// operation object. // operation object.
if (routeReturns && routeReturns.length > 1) {
// TODO ad-hoc model definition in the case of multiple return values. // TODO ad-hoc model definition in the case of multiple return values.
routeReturns = { type: 'object' }; // It is enough to replace 'object' with an anonymous type definition
} else { // based on all routeReturn items. The schema converter should take
// Per the spec: // care of the remaning conversions.
// https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#523-operation-object var def = { type: 'object' };
// This is the only object that may have a type of 'void'. return schemaBuilder.buildFromLoopBackType(def, typeRegistry);
routeReturns = routeReturns[0] || { type: 'void' };
}
return routeReturns;
}, },
/** /**
* Converts from an sl-remoting-formatted "Route" description to a * Converts from an sl-remoting-formatted "Route" description to a
* Swagger-formatted "API" description. * Swagger-formatted "Path Item Object"
* See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#523-operation-object * See swagger-spec/2.0.md#pathItemObject
*/ */
routeToAPIDoc: function routeToAPIDoc(route, classDef) { routeToPathEntry: function(route, classDef, typeRegistry) {
/**
* 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 // Some parameters need to be altered; eventually most of this should
// be removed. // be removed.
var accepts = routeHelper.convertAcceptsToSwagger(route, classDef); var accepts = routeHelper.convertAcceptsToSwagger(route, classDef,
var returns = routeHelper.convertReturnsToSwagger(route, classDef); typeRegistry);
var responseMessages = [ var returns = routeHelper.convertReturnsToSwagger(route, typeRegistry);
{ var defaultCode = route.returns && route.returns.length ? 200 : 204;
code: route.returns && route.returns.length ? 200 : 204, // TODO - support strong-remoting's option for a custom response code
message: 'Request was successful',
responseModel: returns.model || prepareDataType(returns.type) || 'void' var responseMessages = {};
} responseMessages[defaultCode] = {
]; description: 'Request was successful',
schema: returns,
// TODO - headers, examples
};
if (route.errors) { 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); 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), 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), method: routeHelper.convertVerb(route.verb),
// [strml] remove leading model name from op, swagger uses leading operation: {
// path as class name so it remains unique between models. tags: tags,
// 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',
summary: typeConverter.convertText(route.description), summary: typeConverter.convertText(route.description),
notes: typeConverter.convertText(route.notes) description: typeConverter.convertText(route.notes),
}, returns)] // [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) { convertPathFragments: function convertPathFragments(path) {
@ -223,30 +181,27 @@ var routeHelper = module.exports = {
convertVerb: function convertVerb(verb) { convertVerb: function convertVerb(verb) {
if (verb.toLowerCase() === 'all') { if (verb.toLowerCase() === 'all') {
return 'POST'; return 'post';
} }
if (verb.toLowerCase() === 'del') { 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. * to a Swagger-formatted "Parameter" description.
*/ */
acceptToParameter: function acceptToParameter(route) { acceptToParameter: function acceptToParameter(route, classDef, typeRegistry) {
var type = 'form'; var DEFAULT_TYPE =
route.verb.toLowerCase() === 'get' ? 'query' : 'formData';
if (route.verb.toLowerCase() === 'get') {
type = 'query';
}
return function (accepts) { return function (accepts) {
var name = accepts.name || accepts.arg; var name = accepts.name || accepts.arg;
var paramType = type; var paramType = DEFAULT_TYPE;
// TODO: Regex. This is leaky. // TODO: Regex. This is leaky.
if (route.path.indexOf(':' + name) !== -1) { if (route.path.indexOf(':' + name) !== -1) {
@ -254,78 +209,44 @@ var routeHelper = module.exports = {
} }
// Check the http settings for the argument // Check the http settings for the argument
if(accepts.http && accepts.http.source) { if (accepts.http && accepts.http.source) {
paramType = 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, name: name,
required: !!accepts.required, in: paramType,
paramType: paramType || type, description: typeConverter.convertText(accepts.description),
type: accepts.type, required: !!accepts.required
$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)
}; };
out = routeHelper.extendWithType(out, accepts); var schema = schemaBuilder.buildFromLoopBackType(accepts, typeRegistry);
if (paramType === 'body') {
// HACK: Derive the type from model // HACK: Derive the type from model
if(out.name === 'data' && out.type === 'object') { if (paramObject.name === 'data' && schema.type === 'object') {
out.type = route.method.split('.')[0]; 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);
}
} }
return out; return paramObject;
}; };
}, },
/**
* 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;
}
}
}
_assign(obj, typeDesc);
return obj;
}
}; };

170
lib/schema-builder.js Normal file
View File

@ -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;
};

View File

@ -1,38 +1,27 @@
'use strict'; 'use strict';
/**
* Expose the `Swagger` plugin.
*/
module.exports = Swagger;
/** /**
* Module dependencies. * Module dependencies.
*/ */
var path = require('path'); var path = require('path');
var urlJoin = require('./url-join'); var _ = require('lodash');
var _defaults = require('lodash').defaults;
var classHelper = require('./class-helper');
var routeHelper = require('./route-helper'); var routeHelper = require('./route-helper');
var _cloneDeep = require('lodash').cloneDeep;
var modelHelper = require('./model-helper'); var modelHelper = require('./model-helper');
var cors = require('cors'); var cors = require('cors');
var typeConverter = require('./type-converter'); 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} loopbackApplication The application to document.
* @param {Application} swaggerApp Swagger application used for hosting
* these files.
* @param {Object} opts Options. * @param {Object} opts Options.
* @returns {Object}
*/ */
function Swagger(loopbackApplication, swaggerApp, opts) { exports.createSwaggerObject = function(loopbackApplication, opts) {
if (opts && opts.swaggerVersion) opts = _.defaults(opts || {}, {
console.warn('loopback-explorer\'s options.swaggerVersion is deprecated.');
opts = _defaults(opts || {}, {
swaggerVersion: '1.2',
basePath: loopbackApplication.get('restApiRoot') || '/api', basePath: loopbackApplication.get('restApiRoot') || '/api',
resourcePath: 'resources',
// Default consumes/produces // Default consumes/produces
consumes: [ consumes: [
'application/json', 'application/json',
@ -45,7 +34,7 @@ function Swagger(loopbackApplication, swaggerApp, opts) {
// JSONP content types // JSONP content types
'application/javascript', 'text/javascript' 'application/javascript', 'text/javascript'
], ],
version: getVersion() version: getPackagePropertyOrDefault('version', '1.0.0'),
}); });
// We need a temporary REST adapter to discover our available routes. // 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 routes = adapter.allRoutes();
var classes = remotes.classes(); 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 typeRegistry = new TypeRegistry();
var resourceDoc = generateResourceDoc(opts); var loopbackRegistry = loopbackApplication.registry ||
var apiDocs = {}; 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. // A class is an endpoint root; e.g. /users, /products, and so on.
classes.forEach(function (aClass) { // In Swagger 2.0, there is no endpoint roots, but one can group endpoints
var doc = apiDocs[aClass.name] = classHelper.generateAPIDoc(aClass, opts); // using tags.
var hasDocumented = false; classes.forEach(function(aClass) {
var methods = aClass.methods() if (!aClass.name) return;
for (var methodKey in methods) {
hasDocumented = methods[methodKey].documented;
if (hasDocumented) {
break;
}
}
if (hasDocumented) {
resourceDoc.apis.push(classHelper.generateResourceDocAPIEntry(aClass));
}
// Add the getter for this doc. var hasDocumentedMethods = aClass.methods().some(function(m) {
var docPath = urlJoin(opts.resourcePath, aClass.http.path); return m.documented;
addRoute(swaggerApp, docPath, doc, opts); });
if (!hasDocumentedMethods) return;
swaggerObject.tags.push(tagBuilder.buildTagFromClass(aClass));
}); });
// A route is an endpoint, such as /users/findOne. // A route is an endpoint, such as /users/findOne.
routes.forEach(function(route) { routes.forEach(function(route) {
// Get the API doc matching this class name. if (!route.documented) return;
var className = route.method.split('.')[0];
var doc = apiDocs[className];
if (!doc) {
console.error('Route exists with no class: %j', route);
return;
}
// Get the class definition matching this route. // 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; return item.name === className;
})[0]; })[0];
if (route.documented) { if (!classDef) {
routeHelper.addRouteToAPIDeclaration(route, classDef, doc); console.error('Route exists with no class: %j', route);
} return;
});
// Add models referenced from routes (e.g. accepts/returns)
Object.keys(apiDocs).forEach(function(className) {
var classDoc = apiDocs[className];
classDoc.apis.forEach(function(api) {
api.operations.forEach(function(routeDoc) {
routeDoc.parameters.forEach(function(param) {
var type = param.type;
if (type === 'array' && param.items)
type = param.items.type;
addTypeToModels(type);
});
if (routeDoc.type === 'array') {
addTypeToModels(routeDoc.items.type);
} else {
addTypeToModels(routeDoc.type);
} }
routeDoc.responseMessages.forEach(function(msg) { routeHelper.addRouteToSwaggerPaths(route, classDef, typeRegistry,
addTypeToModels(msg.responseModel); swaggerObject.paths);
}); });
function addTypeToModels(name) { _.assign(swaggerObject.definitions, typeRegistry.getDefinitions());
if (!name || name === 'void') return;
var model = loopbackApplication.models[name]; loopbackApplication.emit('swaggerResources', swaggerObject);
if (!model) { return swaggerObject;
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));
}
/** /**
* Add a route to this remoting extension. * Setup Swagger documentation on the given express app.
* @param {Application} app Express application. *
* @param {String} uri Path from which to serve the doc. * @param {Application} loopbackApplication The loopback application to
* @param {Object} doc Doc to serve. * 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 resourcePath = opts && opts.resourcePath || 'swagger.json';
var initialPath = doc.basePath || ''; if (resourcePath[0] !== '/') resourcePath = '/' + resourcePath;
// Remove the trailing slash, see var remotes = loopbackApplication.remotes();
// https://github.com/strongloop/loopback-explorer/issues/48 setupCors(swaggerApp, remotes);
if (initialPath[initialPath.length-1] === '/')
initialPath = initialPath.slice(0, -1);
app.get(urlJoin('/', uri), function(req, res) { swaggerApp.get(resourcePath, function sendSwaggerObject(req, res) {
res.status(200).send(swaggerObject);
// 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);
}); });
} };
/** /**
* Generate a top-level resource doc. This is the entry point for swagger UI * 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. * @param {Object} opts Swagger options.
* @return {Object} Resource doc. * @return {Object} Resource doc.
*/ */
function generateResourceDoc(opts) { function generateSwaggerObjectBase(opts) {
var apiInfo = _cloneDeep(opts.apiInfo); var apiInfo = _.cloneDeep(opts.apiInfo) || {};
for (var propertyName in apiInfo) { for (var propertyName in apiInfo) {
var property = apiInfo[propertyName]; var property = apiInfo[propertyName];
apiInfo[propertyName] = typeConverter.convertText(property); 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 { return {
swaggerVersion: opts.swaggerVersion, swagger: '2.0',
apiVersion: opts.version, // See swagger-spec/2.0.md#infoObject
// See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#513-info-object
info: apiInfo, info: apiInfo,
// TODO Authorizations host: opts.host,
// https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#514-authorizations-object basePath: basePath,
consumes: ['application/json', 'application/xml', 'text/xml'], schemes: opts.protocol ? [opts.protocol] : undefined,
produces: ['application/json', 'application/javascript', 'application/xml', 'text/javascript', 'text/xml'], consumes: opts.consumes,
apis: [], produces: opts.produces,
models: opts.models paths: {},
definitions: opts.models || {},
// TODO Authorizations (security, securityDefinitions)
// TODO: responses, externalDocs
tags: []
}; };
} }
/** function setupCors(swaggerApp, remotes) {
* Attempt to get the current API version from package.json. var corsOptions = remotes.options && remotes.options.cors ||
* @return {String} API Version. { origin: true, credentials: true };
*/
function getVersion() { // TODO(bajtos) Skip CORS when remotes.options.cors === false
var version; swaggerApp.use(cors(corsOptions));
try { }
version = require(path.join(process.cwd(), 'package.json')).version;
} catch(e) { function getPackagePropertyOrDefault(name, defautValue) {
version = '1.0.0'; try {
} var pkg = require(path.join(process.cwd(), 'package.json'));
return version; return pkg[name] || defautValue;
} catch(e) {
return defautValue;
}
} }

18
lib/tag-builder.js Normal file
View File

@ -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 }
};
};

View File

@ -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;
};

View File

@ -1,3 +1,5 @@
'use strict';
var typeConverter = module.exports = { var typeConverter = module.exports = {
/** /**

43
lib/type-registry.js Normal file
View File

@ -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;
};

View File

@ -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'});
}

View File

@ -41,7 +41,7 @@ describe('explorer', function() {
.end(function(err, res) { .end(function(err, res) {
if (err) return done(err); if (err) return done(err);
expect(res.body).to expect(res.body).to
.have.property('url', '/explorer/resources'); .have.property('url', '/explorer/swagger.json');
done(); done();
}); });
}); });
@ -58,7 +58,7 @@ describe('explorer', function() {
.end(function(err, res) { .end(function(err, res) {
if (err) return done(err); if (err) return done(err);
expect(res.body).to expect(res.body).to
.have.property('url', '/swagger/resources'); .have.property('url', '/swagger/swagger.json');
done(); done();
}); });
}); });
@ -76,7 +76,7 @@ describe('explorer', function() {
.end(function(err, res) { .end(function(err, res) {
if (err) return done(err); if (err) return done(err);
expect(res.body).to expect(res.body).to
.have.property('url', '/explorer/resources'); .have.property('url', '/explorer/swagger.json');
done(); done();
}); });
}); });
@ -86,17 +86,17 @@ describe('explorer', function() {
// Since the resource paths are always startign with a slash, // Since the resource paths are always startign with a slash,
// if the basePath ends with a slash too, an incorrect URL is produced // if the basePath ends with a slash too, an incorrect URL is produced
var app = loopback(); var app = loopback();
app.set('restApiRoot', '/'); app.set('restApiRoot', '/apis/');
configureRestApiAndExplorer(app); configureRestApiAndExplorer(app);
request(app) request(app)
.get('/explorer/resources/products') .get('/explorer/swagger.json')
.expect(200) .expect(200)
.end(function(err, res) { .end(function(err, res) {
if (err) return done(err); if (err) return done(err);
var baseUrl = res.body.basePath; var baseUrl = res.body.basePath;
var apiPath = res.body.apis[0].path; var apiPath = Object.keys(res.body.paths)[0];
expect(baseUrl + apiPath).to.match(/http:\/\/[^\/]+\/products/); expect(baseUrl + apiPath).to.equal('/apis/products');
done(); done();
}); });
}); });
@ -107,7 +107,7 @@ describe('explorer', function() {
beforeEach(function setupExplorerWithUiDirs() { beforeEach(function setupExplorerWithUiDirs() {
app = loopback(); app = loopback();
explorer(app, { 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) { it('should allow `uiDirs` to be defined as an Array', function(done) {
explorer(app, { explorer(app, {
uiDirs: [ path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui') ] uiDirs: [path.resolve(__dirname, 'fixtures', 'dummy-swagger-ui')]
}); });
request(app).get('/explorer/') request(app).get('/explorer/')
@ -194,6 +194,7 @@ describe('explorer', function() {
app.model(Product); app.model(Product);
explorer(app, { mountPath: explorerBase }); explorer(app, { mountPath: explorerBase });
app.set('legacyExplorer', false);
app.use(app.get('restApiRoot') || '/', loopback.rest()); app.use(app.get('restApiRoot') || '/', loopback.rest());
} }
}); });

View File

@ -1,153 +1,14 @@
'use strict'; 'use strict';
var modelHelper = require('../lib/model-helper'); var modelHelper = require('../lib/model-helper');
var TypeRegistry = require('../lib/type-registry');
var _defaults = require('lodash').defaults; var _defaults = require('lodash').defaults;
var loopback = require('loopback'); var loopback = require('loopback');
var expect = require('chai').expect; var expect = require('chai').expect;
describe('model-helper', function() { 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() { describe('related models', function() {
it('should include related models', function () { it('should include related models', function() {
var defs = buildSwaggerModelsWithRelations({ var defs = buildSwaggerModelsWithRelations({
str: String // 'string' str: String // 'string'
}); });
@ -161,7 +22,7 @@ describe('model-helper', function() {
str: String, // 'string' str: String, // 'string'
address: Model2 address: Model2
}, { models: { Model2: Model2 } }); }, { models: { Model2: Model2 } });
var defs = modelHelper.generateModelDefinition(Model1, {}); var defs = getDefinitionsForModel(Model1);
expect(defs).has.property('Model1'); expect(defs).has.property('Model1');
expect(defs).has.property('Model2'); expect(defs).has.property('Model2');
}); });
@ -171,7 +32,7 @@ describe('model-helper', function() {
var Model3 = loopback.createModel('Model3', { var Model3 = loopback.createModel('Model3', {
str: String // 'string' str: String // 'string'
}, {models: {model4: 'Model4'}}); }, {models: {model4: 'Model4'}});
var defs = modelHelper.generateModelDefinition(Model3, {}); var defs = getDefinitionsForModel(Model3);
expect(defs).has.property('Model3'); expect(defs).has.property('Model3');
expect(defs).has.property('Model4'); expect(defs).has.property('Model4');
}); });
@ -182,7 +43,7 @@ describe('model-helper', function() {
str: String, // 'string' str: String, // 'string'
addresses: [Model6] addresses: [Model6]
}, { models: { Model6: Model6 } }); }, { models: { Model6: Model6 } });
var defs = modelHelper.generateModelDefinition(Model5, {}); var defs = getDefinitionsForModel(Model5);
expect(defs).has.property('Model5'); expect(defs).has.property('Model5');
expect(defs).has.property('Model6'); expect(defs).has.property('Model6');
}); });
@ -193,7 +54,7 @@ describe('model-helper', function() {
var Model7 = loopback.createModel('Model7', {street: String}); var Model7 = loopback.createModel('Model7', {street: String});
Array.prototype.customFunc = function() { Array.prototype.customFunc = function() {
}; };
var defs = modelHelper.generateModelDefinition(Model7, {}); var defs = getDefinitionsForModel(Model7);
expect(defs).has.property('Model7'); expect(defs).has.property('Model7');
expect(Object.keys(defs)).has.property('length', 1); expect(Object.keys(defs)).has.property('length', 1);
}); });
@ -207,7 +68,11 @@ describe('model-helper', function() {
through: 'appointment' 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'); expect(Object.keys(defs)).to.not.contain('hasMany');
}); });
}); });
@ -221,26 +86,24 @@ describe('model-helper', function() {
aClass.ctor.definition.settings = { aClass.ctor.definition.settings = {
hidden: ['hiddenProperty'] 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.not.have.property('hiddenProperty');
expect(def.properties).to.have.property('visibleProperty'); expect(def.properties).to.have.property('visibleProperty');
}); });
}); });
describe('#generateModelDefinition', function() { it('should convert top level array description to string', function() {
it('should convert top level array description to string', function () {
var model = {}; var model = {};
model.definition = { model.definition = {
name: 'test', name: 'test',
description: ['1', '2', '3'], description: ['1', '2', '3'],
properties: {} properties: {}
}; };
var models = {}; var defs = getDefinitionsForModel(model);
modelHelper.generateModelDefinition(model, models); expect(defs.test.description).to.equal('1\n2\n3');
expect(models.test.description).to.equal("1\n2\n3");
}); });
it('should convert property level array description to string', function () { it('should convert property level array description to string', function() {
var model = {}; var model = {};
model.definition = { model.definition = {
name: 'test', name: 'test',
@ -251,17 +114,14 @@ describe('model-helper', function() {
} }
} }
}; };
var models = {}; var defs = getDefinitionsForModel(model);
modelHelper.generateModelDefinition(model, models); expect(defs.test.properties.prop1.description).to.equal('1\n2\n3');
expect(models.test.properties.prop1.description).to.equal("1\n2\n3");
});
}); });
describe('getPropType', function() { it('omits empty "required" array', function() {
it('converts anonymous object types', function() { var aClass = createModelCtor({});
var type = modelHelper.getPropType({ name: 'string', value: 'string' }); var def = getDefinitionsForModel(aClass.ctor).testModel;
expect(type).to.eql('object'); 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();
}

View File

@ -1,33 +1,35 @@
'use strict'; 'use strict';
var routeHelper = require('../lib/route-helper'); var routeHelper = require('../lib/route-helper');
var TypeRegistry = require('../lib/type-registry');
var expect = require('chai').expect; var expect = require('chai').expect;
var _defaults = require('lodash').defaults; var _defaults = require('lodash').defaults;
describe('route-helper', function() { describe('route-helper', function() {
it('returns "object" when a route has multiple return values', function() { it('returns "object" when a route has multiple return values', function() {
var doc = createAPIDoc({ var entry = createAPIDoc({
returns: [ returns: [
{ arg: 'max', type: 'number' }, { arg: 'max', type: 'number' },
{ arg: 'min', type: 'number' }, { arg: 'min', type: 'number' },
{ arg: 'avg', type: 'number' } { arg: 'avg', type: 'number' }
] ]
}); });
expect(doc.operations[0].type).to.equal('object'); // TODO use a custom (dynamicaly-created) model schema instead of "object"
expect(getResponseType(doc.operations[0])).to.equal('object'); expect(getResponseMessage(entry.operation))
.to.have.property('schema').eql({ type: 'object' });
}); });
it('converts path params when they exist in the route name', function() { it('converts path params when they exist in the route name', function() {
var doc = createAPIDoc({ var entry = createAPIDoc({
accepts: [ accepts: [
{arg: 'id', type: 'string'} {arg: 'id', type: 'string'}
], ],
path: '/test/:id' path: '/test/:id'
}); });
var paramDoc = doc.operations[0].parameters[0]; var paramDoc = entry.operation.parameters[0];
expect(paramDoc.paramType).to.equal('path'); expect(paramDoc).to.have.property('in', 'path');
expect(paramDoc.name).to.equal('id'); expect(paramDoc).to.have.property('name', 'id');
expect(paramDoc.required).to.equal(false); expect(paramDoc).to.have.property('required', false);
}); });
// FIXME need regex in routeHelper.acceptToParameter // FIXME need regex in routeHelper.acceptToParameter
@ -38,8 +40,8 @@ describe('route-helper', function() {
], ],
path: '/test/:identifier' path: '/test/:identifier'
}); });
var paramDoc = doc.operations[0].parameters[0]; var paramDoc = doc.operation.parameters[0];
expect(paramDoc.paramType).to.equal('query'); expect(paramDoc.in).to.equal('query');
}); });
it('correctly coerces param types', function() { it('correctly coerces param types', function() {
@ -48,71 +50,77 @@ describe('route-helper', function() {
{arg: 'binaryData', type: 'buffer'} {arg: 'binaryData', type: 'buffer'}
] ]
}); });
var paramDoc = doc.operations[0].parameters[0]; var paramDoc = doc.operation.parameters[0];
expect(paramDoc.paramType).to.equal('query'); expect(paramDoc).to.have.property('in', 'query');
expect(paramDoc.type).to.equal('string'); expect(paramDoc).to.have.property('type', 'string');
expect(paramDoc.format).to.equal('byte'); expect(paramDoc).to.have.property('format', 'byte');
}); });
it('correctly converts return types (arrays)', function() { it('correctly converts return types (arrays)', function() {
var doc = createAPIDoc({ var doc = createAPIDoc({
returns: [ returns: [
{arg: 'data', type: ['customType']} { arg: 'data', type: ['customType'], root: true }
] ]
}); });
var opDoc = doc.operations[0]; var opDoc = doc.operation;
expect(getResponseType(opDoc)).to.eql('[customType]');
// NOTE(bajtos) this would be the case if there was a single response type var responseSchema = getResponseMessage(opDoc).schema;
expect(opDoc.type).to.equal('array'); expect(responseSchema).to.have.property('type', 'array');
expect(opDoc.items).to.eql({type: 'customType'}); expect(responseSchema).to.have.property('items')
.eql({ $ref: '#/definitions/customType' });
}); });
it('correctly converts return types (format)', function() { it('correctly converts return types (format)', function() {
var doc = createAPIDoc({ var doc = createAPIDoc({
returns: [ returns: [
{arg: 'data', type: 'buffer'} { arg: 'data', type: 'buffer', root: true }
] ]
}); });
var opDoc = doc.operations[0];
expect(opDoc.type).to.equal('string'); var responseSchema = getResponseMessage(doc.operation).schema;
expect(opDoc.format).to.equal('byte'); 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({ var doc = createAPIDoc({
notes: 'some notes' notes: 'some notes'
}); });
expect(doc.operations[0].notes).to.equal('some notes'); expect(doc.operation).to.have.property('description', 'some notes');
}); });
describe('#acceptToParameter', function(){ describe('#acceptToParameter', function() {
it('should return function that converts accepts.description from array of string to string', function(){ var A_CLASS_DEF = { name: 'TestModelName' };
var f = routeHelper.acceptToParameter({verb: 'get', path: 'path'});
var result = f({description: ['1','2','3']}); it('returns fn converting description from array to string', function() {
expect(result.description).to.eql("1\n2\n3"); 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() { describe('#routeToPathEntry', function() {
it('should convert route.description from array of string to string', function () { it('converts route.description from array to string', function() {
var result = routeHelper.routeToAPIDoc({ var result = routeHelper.routeToPathEntry({
method: 'someMethod', method: 'someMethod',
verb: 'get', verb: 'get',
path: 'path', path: 'path',
description: ['1', '2', '3'] 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 () { it('converts route.notes from array of string to string', function() {
var result = routeHelper.routeToAPIDoc({ var result = routeHelper.routeToPathEntry({
method: 'someMethod', method: 'someMethod',
verb: 'get', verb: 'get',
path: 'path', path: 'path',
notes: ['1', '2', '3'] 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({ var doc = createAPIDoc({
deprecated: 'true' deprecated: 'true'
}); });
expect(doc.operations[0].deprecated).to.equal('true'); expect(doc.operation).to.have.property('deprecated', true);
}); });
it('joins array description/summary', function() { it('joins array description/summary', function() {
var doc = createAPIDoc({ var doc = createAPIDoc({
description: [ 'line1', 'line2' ] 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() { it('joins array notes', function() {
var doc = createAPIDoc({ var doc = createAPIDoc({
notes: [ 'line1', 'line2' ] 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() { it('joins array description/summary of an input arg', function() {
var doc = createAPIDoc({ var doc = createAPIDoc({
accepts: [{ name: 'arg', description: [ 'line1', 'line2' ] }] 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() { it('correctly does not include context params', function() {
@ -151,7 +159,7 @@ describe('route-helper', function() {
], ],
path: '/test' path: '/test'
}); });
var params = doc.operations[0].parameters; var params = doc.operation.parameters;
expect(params.length).to.equal(0); expect(params.length).to.equal(0);
}); });
@ -162,7 +170,7 @@ describe('route-helper', function() {
], ],
path: '/test' path: '/test'
}); });
var params = doc.operations[0].parameters; var params = doc.operation.parameters;
expect(params.length).to.equal(0); expect(params.length).to.equal(0);
}); });
@ -173,7 +181,7 @@ describe('route-helper', function() {
], ],
path: '/test' path: '/test'
}); });
var params = doc.operations[0].parameters; var params = doc.operation.parameters;
expect(params.length).to.equal(0); expect(params.length).to.equal(0);
}); });
@ -181,7 +189,7 @@ describe('route-helper', function() {
var doc = createAPIDoc({ var doc = createAPIDoc({
accepts: [{ name: 'arg', type: 'number', enum: [1,2,3] }] 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]); .to.have.property('enum').eql([1,2,3]);
}); });
@ -189,28 +197,24 @@ describe('route-helper', function() {
var doc = createAPIDoc({ var doc = createAPIDoc({
returns: [{ name: 'result', type: 'object', root: true }] returns: [{ name: 'result', type: 'object', root: true }]
}); });
expect(doc.operations[0].type).to.eql('object'); expect(doc.operation.responses).to.eql({
expect(doc.operations[0].responseMessages).to.eql([ 200: {
{ description: 'Request was successful',
code: 200, schema: { type: 'object' }
message: 'Request was successful',
responseModel: 'object'
} }
]); });
}); });
it('uses the response code 204 when `returns` is empty', function() { it('uses the response code 204 when `returns` is empty', function() {
var doc = createAPIDoc({ var doc = createAPIDoc({
returns: [] returns: []
}); });
expect(doc.operations[0].type).to.eql('void'); expect(doc.operation.responses).to.eql({
expect(doc.operations[0].responseMessages).to.eql([ 204: {
{ description: 'Request was successful',
code: 204, schema: undefined
message: 'Request was successful',
responseModel: 'void'
} }
]); });
}); });
it('includes custom error response in `responseMessages`', function() { it('includes custom error response in `responseMessages`', function() {
@ -221,36 +225,56 @@ describe('route-helper', function() {
responseModel: 'ValidationError' responseModel: 'ValidationError'
}] }]
}); });
expect(doc.operations[0].responseMessages[1]).to.eql({ expect(doc.operation.responses).to.have.property(422).eql({
code: 422, description: 'Validation failed',
message: 'Validation failed', schema: { $ref: '#/definitions/ValidationError' }
responseModel: 'ValidationError'
}); });
}); });
it('route nickname does not include model name.', function() { it('route operationId DOES include model name.', function() {
var doc = createAPIDoc(); var doc = createAPIDoc({ method: 'User.login' });
expect(doc.operations[0].nickname).to.equal('get'); expect(doc.operation.operationId).to.equal('User.login');
}); });
it('route nickname with a period is shorted correctly', function() { it('adds class name to `tags`', function() {
// Method is built by remoting to always be #{className}.#{methodName} 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({ 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 // Easy wrapper around createRoute
function createAPIDoc(def) { function createAPIDoc(def, classDef) {
return routeHelper.routeToAPIDoc(_defaults(def || {}, { return routeHelper.routeToPathEntry(_defaults(def || {}, {
path: '/test', path: '/test',
verb: 'GET', verb: 'GET',
method: 'test.get' method: 'test.get'
})); }), classDef, new TypeRegistry());
} }
function getResponseType(operationDoc) { function getResponseMessage(operationDoc) {
return operationDoc.responseMessages[0].responseModel; return operationDoc.responses[200] || operationDoc.responses[204]
|| operationDoc.responses.default;
} }

100
test/schema-builder.test.js Normal file
View File

@ -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);
}
});

View File

@ -9,220 +9,180 @@ var request = require('supertest');
var expect = require('chai').expect; var expect = require('chai').expect;
describe('swagger definition', function() { 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() { describe('basePath', function() {
// No basepath on resource doc in 1.2 it('is "{basePath}" when basePath is a path', function() {
it('no longer exists on resource doc', function(done) { var app = createLoopbackAppWithModel();
var app = givenAppWithSwagger(); var swaggerResource = swagger.createSwaggerObject(app, {
basePath: '/api-root'
var getReq = getSwaggerResources(app);
getReq.end(function(err, res) {
if (err) return done(err);
expect(res.body.basePath).to.equal(undefined);
done();
});
}); });
it('is "http://{host}/api" by default', function(done) { expect(swaggerResource.basePath).to.equal('/api-root');
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 "http://{host}/{basePath}" when basePath is a path', function(done){ it('is inferred from app.get("apiRoot")', function() {
var app = givenAppWithSwagger({ basePath: '/api-root'}); var app = createLoopbackAppWithModel();
app.set('restApiRoot', '/custom-api-root');
var getReq = getAPIDeclaration(app, 'products'); var swaggerResource = swagger.createSwaggerObject(app);
getReq.end(function(err, res) { expect(swaggerResource.basePath).to.equal('/custom-api-root');
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){ it('is reachable when explorer mounting location is changed',
var app = givenAppWithSwagger({}, {apiRoot: '/custom-api-root'}); function(done) {
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){
var explorerRoot = '/erforscher'; var explorerRoot = '/erforscher';
var app = givenAppWithSwagger({}, {explorerRoot: explorerRoot}); var app = givenAppWithSwagger({}, {explorerRoot: explorerRoot});
var getReq = getSwaggerResources(app, explorerRoot, 'products'); getSwaggerResource(app, explorerRoot).end(function(err, res) {
getReq.end(function(err, res) {
if (err) return done(err); if (err) return done(err);
expect(res.body.basePath).to.be.a('string'); expect(res.body.basePath).to.be.a('string');
done(); done();
}); });
}); });
it('respects a hardcoded protocol (behind SSL terminator)', function(done){ it('respects a hardcoded protocol (behind SSL terminator)', function() {
var app = givenAppWithSwagger({protocol: 'https'}); var app = createLoopbackAppWithModel();
var swaggerResource = swagger.createSwaggerObject(app, {
protocol: 'https'
});
expect(swaggerResource.schemes).to.eql(['https']);
});
var getReq = getAPIDeclaration(app, 'products'); it('supports opts.host', function() {
getReq.end(function(err, res) { var app = createLoopbackAppWithModel();
if (err) return done(err); var swaggerResource = swagger.createSwaggerObject(app, {
var parsed = url.parse(res.body.basePath); host: 'example.com:8080'
expect(parsed.protocol).to.equal('https:'); });
done(); expect(swaggerResource.host).to.equal('example.com:8080');
}); });
}); });
it('respects X-Forwarded-Host header (behind a proxy)', function(done) { it('has global "consumes"', function() {
var app = givenAppWithSwagger(); var app = createLoopbackAppWithModel();
getAPIDeclaration(app, 'products') var swaggerResource = swagger.createSwaggerObject(app);
.set('X-Forwarded-Host', 'example.com') expect(swaggerResource.consumes).to.have.members([
.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.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();
});
});
});
describe('Model definition attributes', function() {
it('Properly defines basic attributes', function(done) {
var app = givenAppWithSwagger();
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('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/json',
'application/x-www-form-urlencoded', 'application/x-www-form-urlencoded',
'application/xml', 'text/xml' 'application/xml', 'text/xml'
]); ]);
done();
});
}); });
it('includes `produces`', function(done) { it('has global "produces"', function() {
var app = givenAppWithSwagger(); var app = createLoopbackAppWithModel();
getAPIDeclaration(app, 'products').end(function(err, res) { var swaggerResource = swagger.createSwaggerObject(app);
if (err) return done(err); expect(swaggerResource.produces).to.have.members([
expect(res.body.produces).to.have.members([
'application/json', 'application/json',
'application/xml', 'text/xml', 'application/xml', 'text/xml',
// JSONP content types // JSONP content types
'application/javascript', 'text/javascript' 'application/javascript', 'text/javascript'
]); ]);
done(); });
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' }
]);
}); });
}); });
it('includes models from `accepts` args', function(done) { 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 models from "accepts" args', function() {
var app = createLoopbackAppWithModel(); var app = createLoopbackAppWithModel();
givenPrivateAppModel(app, 'Image'); givenPrivateAppModel(app, 'Image');
givenSharedMethod(app.models.Product, 'setImage', { givenSharedMethod(app.models.Product, 'setImage', {
accepts: { name: 'image', type: 'Image' } accepts: { name: 'image', type: 'Image' }
}); });
mountExplorer(app);
getAPIDeclaration(app, 'products').end(function(err, res) { var swaggerResource = swagger.createSwaggerObject(app);
expect(Object.keys(res.body.models)).to.include('Image'); expect(Object.keys(swaggerResource.definitions)).to.include('Image');
done();
});
}); });
it('includes models from `returns` args', function(done) { it('includes models from "returns" args', function() {
var app = createLoopbackAppWithModel(); var app = createLoopbackAppWithModel();
givenPrivateAppModel(app, 'Image'); givenPrivateAppModel(app, 'Image');
givenSharedMethod(app.models.Product, 'getImage', { 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();
});
}); });
it('includes `accepts` models not attached to the app', function(done) { var swaggerResource = swagger.createSwaggerObject(app);
expect(Object.keys(swaggerResource.definitions)).to.include('Image');
});
it('includes "accepts" models not attached to the app', function() {
var app = createLoopbackAppWithModel(); var app = createLoopbackAppWithModel();
loopback.createModel('Image'); loopback.createModel('Image');
givenSharedMethod(app.models.Product, 'setImage', { givenSharedMethod(app.models.Product, 'setImage', {
accepts: { name: 'image', type: 'Image' } accepts: { name: 'image', type: 'Image' }
}); });
mountExplorer(app);
getAPIDeclaration(app, 'products').end(function(err, res) { var swaggerResource = swagger.createSwaggerObject(app);
expect(Object.keys(res.body.models)).to.include('Image'); expect(Object.keys(swaggerResource.definitions)).to.include('Image');
done();
});
}); });
it('includes `responseMessages` models', function(done) { it('includes "responseMessages" models', function() {
var app = createLoopbackAppWithModel(); var app = createLoopbackAppWithModel();
loopback.createModel('ValidationError'); loopback.createModel('ValidationError');
givenSharedMethod(app.models.Product, 'setImage', { 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(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); givenWarehouseWithAddressModels(app);
app.models.Product.defineProperty('location', { type: 'Warehouse' }); 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(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); givenWarehouseWithAddressModels(app);
app.models.Product.defineProperty('location', { type: ['Warehouse'] }); 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(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); givenWarehouseWithAddressModels(app);
app.models.Product.belongsTo(app.models.Warehouse); 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(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); givenWarehouseWithAddressModels(app);
givenPrivateAppModel(app, 'ProductLocations'); givenPrivateAppModel(app, 'ProductLocations');
@ -271,13 +239,12 @@ describe('swagger definition', function() {
app.models.Product.hasMany(app.models.Warehouse, app.models.Product.hasMany(app.models.Warehouse,
{ through: app.models.ProductLocations }); { through: app.models.ProductLocations });
expectProductDocIncludesModels( var swaggerResource = swagger.createSwaggerObject(app);
app, expect(Object.keys(swaggerResource.definitions))
['Address', 'Warehouse', 'ProductLocations'], .to.include.members(['Address', 'Warehouse', 'ProductLocations']);
done);
}); });
it('includes nested model references in accept args', function(done) { it('includes nested model references in accept args', function() {
var app = createLoopbackAppWithModel(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); givenWarehouseWithAddressModels(app);
@ -285,21 +252,25 @@ describe('swagger definition', function() {
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 array model references in accept args', function(done) { it('includes nested array model references in accept args', function() {
var app = createLoopbackAppWithModel(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); givenWarehouseWithAddressModels(app);
givenSharedMethod(app.models.Product, 'aMethod', { 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(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); givenWarehouseWithAddressModels(app);
@ -307,10 +278,12 @@ describe('swagger definition', function() {
returns: { arg: 'w', type: 'Warehouse', root: true } 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(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); givenWarehouseWithAddressModels(app);
@ -318,10 +291,12 @@ describe('swagger definition', function() {
returns: { arg: 'w', type: ['Warehouse'], root: true } 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(); var app = createLoopbackAppWithModel();
givenWarehouseWithAddressModels(app); 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) { it('allows cross-origin requests by default', function(done) {
var app = givenAppWithSwagger(); var app = givenAppWithSwagger();
request(app) request(app)
.options('/explorer/resources') .options('/explorer/swagger.json')
.set('Origin', 'http://example.com/') .set('Origin', 'http://example.com/')
.expect('Access-Control-Allow-Origin', /^http:\/\/example.com\/|\*/) .expect('Access-Control-Allow-Origin', /^http:\/\/example.com\/|\*/)
.expect('Access-Control-Allow-Methods', /\bGET\b/) .expect('Access-Control-Allow-Methods', /\bGET\b/)
@ -349,9 +326,11 @@ describe('swagger definition', function() {
}); });
it('can be disabled by configuration', function(done) { it('can be disabled by configuration', function(done) {
var app = givenAppWithSwagger({}, { remoting: { cors: { origin: false } } }); var app = givenAppWithSwagger({}, {
remoting: { cors: { origin: false } }
});
request(app) request(app)
.options('/explorer/resources') .options('/explorer/swagger.json')
.end(function(err, res) { .end(function(err, res) {
if (err) return done(err); if (err) return done(err);
var allowOrigin = res.get('Access-Control-Allow-Origin'); 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) return request(app)
.get(urlJoin(restPath || '/explorer', '/resources', classPath || '')) .get(urlJoin(restPath || '/explorer', '/swagger.json'))
.set('Accept', 'application/json') .set('Accept', 'application/json')
.expect(200) .expect(200)
.expect('Content-Type', /json/); .expect('Content-Type', /json/);
} }
function getAPIDeclaration(app, className) { function getAPIDeclaration(app, className) {
return getSwaggerResources(app, '', urlJoin('/', className)); return getSwaggerResource(app, '', urlJoin('/', className));
} }
function givenAppWithSwagger(swaggerOptions, appConfig) { function givenAppWithSwagger(swaggerOptions, appConfig) {
@ -387,7 +367,7 @@ describe('swagger definition', function() {
function mountExplorer(app, options) { function mountExplorer(app, options) {
var swaggerApp = loopback(); var swaggerApp = loopback();
swagger(app, swaggerApp, options); swagger.mountSwagger(app, swaggerApp, options);
app.use(app.get('explorerRoot') || '/explorer', swaggerApp); app.use(app.get('explorerRoot') || '/explorer', swaggerApp);
return app; return app;
} }
@ -397,12 +377,12 @@ describe('swagger definition', function() {
app.dataSource('db', { connector: 'memory' }); app.dataSource('db', { connector: 'memory' });
var Product = loopback.Model.extend('product', { var Product = loopback.createModel('Product', {
foo: {type: 'string', required: true}, foo: {type: 'string', required: true},
bar: 'string', bar: 'string',
aNum: {type: 'number', min: 1, max: 10, required: true, default: 5} aNum: {type: 'number', min: 1, max: 10, required: true, default: 5}
}); }, { description: ['a-description', 'line2'] });
app.model(Product, { dataSource: 'db'}); app.model(Product, { dataSource: 'db' });
// Simulate a restApiRoot set in config // Simulate a restApiRoot set in config
app.set('restApiRoot', apiRoot || '/api'); app.set('restApiRoot', apiRoot || '/api');
@ -412,13 +392,13 @@ describe('swagger definition', function() {
} }
function givenSharedMethod(model, name, metadata) { function givenSharedMethod(model, name, metadata) {
model[name] = function(){}; model[name] = function() {};
loopback.remoteMethod(model[name], metadata); loopback.remoteMethod(model[name], metadata);
} }
function givenPrivateAppModel(app, name, properties) { function givenPrivateAppModel(app, name, properties) {
var model = loopback.createModel(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) { function givenWarehouseWithAddressModels(app) {
@ -427,16 +407,4 @@ describe('swagger definition', function() {
shippingAddress: { type: 'Address' } 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();
});
}
}); });

23
test/tag-builder.test.js Normal file
View File

@ -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');
});
});