Refactoring swagger 1.2 rework.
Added comments, api version, and better Swagger 1.2 compat.
This commit is contained in:
parent
d34304afc3
commit
4c0ce42001
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"node": true,
|
||||
"camelcase" : true,
|
||||
"eqnull" : true,
|
||||
"indent": 2,
|
||||
"undef": true,
|
||||
"quotmark": "single",
|
||||
"maxlen": 80,
|
||||
"trailing": true,
|
||||
"newcap": true,
|
||||
"nonew": true,
|
||||
"undef": false
|
||||
}
|
12
index.js
12
index.js
|
@ -1,7 +1,9 @@
|
|||
'use strict';
|
||||
/*!
|
||||
* Adds dynamically-updated docs as /explorer
|
||||
*/
|
||||
var path = require('path');
|
||||
var _defaults = require('lodash.defaults');
|
||||
var extend = require('util')._extend;
|
||||
var loopback = require('loopback');
|
||||
var express = requireLoopbackDependency('express');
|
||||
|
@ -20,8 +22,12 @@ module.exports = explorer;
|
|||
*/
|
||||
|
||||
function explorer(loopbackApplication, options) {
|
||||
options = extend({}, options);
|
||||
options.basePath = options.basePath || loopbackApplication.get('restApiRoot') || '';
|
||||
options = _defaults({}, options, {
|
||||
basePath: loopbackApplication.get('restApiRoot') || '/',
|
||||
name: 'swagger',
|
||||
resourcePath: 'resources',
|
||||
apiInfo: loopbackApplication.get('apiInfo') || {}
|
||||
});
|
||||
|
||||
swagger(loopbackApplication.remotes(), options);
|
||||
|
||||
|
@ -31,7 +37,7 @@ function explorer(loopbackApplication, options) {
|
|||
|
||||
app.get('/config.json', function(req, res) {
|
||||
res.send({
|
||||
url: options.basePath + '/swagger/resources'
|
||||
url: path.join(options.basePath, options.name, options.resourcePath)
|
||||
});
|
||||
});
|
||||
// Allow specifying a static file root for swagger files. Any files in that folder
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
var modelHelper = require('./model-helper');
|
||||
var path = require('path');
|
||||
|
||||
/**
|
||||
* Export the classHelper singleton.
|
||||
*/
|
||||
var classHelper = module.exports = {
|
||||
// See below.
|
||||
addDynamicBasePathGetter: addDynamicBasePathGetter,
|
||||
/**
|
||||
* Given a remoting class, generate an API doc.
|
||||
* See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#52-api-declaration
|
||||
* @param {Class} aClass Strong Remoting class.
|
||||
* @param {Object} opts Options (passed from Swagger(remotes, options))
|
||||
* @param {String} opts.version API Version.
|
||||
* @param {String} opts.swaggerVersion Swagger version.
|
||||
* @param {String} opts.basePath Basepath (usually e.g. http://localhost:3000).
|
||||
* @param {String} opts.resourcePath Resource path (usually /swagger/resources).
|
||||
* @return {Object} API Declaration.
|
||||
*/
|
||||
generateAPIDoc: function(aClass, opts) {
|
||||
return {
|
||||
apiVersion: opts.version,
|
||||
swaggerVersion: opts.swaggerVersion,
|
||||
basePath: opts.basePath,
|
||||
resourcePath: path.join('/', opts.resourcePath),
|
||||
apis: [],
|
||||
models: modelHelper.generateModelDefinition(aClass)
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Given a remoting class, generate a reference to an API declaration.
|
||||
* This is meant for insertion into the Resource declaration.
|
||||
* See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#512-resource-object
|
||||
* @param {Class} aClass Strong Remoting class.
|
||||
* @return {Object} API declaration reference.
|
||||
*/
|
||||
generateResourceDocAPIEntry: function(aClass) {
|
||||
return {
|
||||
path: aClass.http.path,
|
||||
description: aClass.ctor.sharedCtor && aClass.ctor.sharedCtor.description
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* There's a few forces at play that require this "hack". The Swagger spec
|
||||
* requires a `basePath` to be set at various points in the API/Resource
|
||||
* descriptions. However, we can't guarantee this path is either reachable or
|
||||
* desirable if it's set as a part of the options.
|
||||
*
|
||||
* The simplest way around this is to reflect the value of the `Host` HTTP
|
||||
* header as the `basePath`. Because we pre-build the Swagger data, we don't
|
||||
* know that header at the time the data is built. Hence, the getter function.
|
||||
* We can use a `before` hook to pluck the `Host`, then the getter kicks in to
|
||||
* return that path as the `basePath` during JSON serialization.
|
||||
*
|
||||
* @param {SharedClassCollection} remotes The Collection to register a `before`
|
||||
* hook on.
|
||||
* @param {String} path The full path of the route to register
|
||||
* a `before` hook on.
|
||||
* @param {Object} obj The Object to install the `basePath`
|
||||
* getter on.
|
||||
*/
|
||||
function addDynamicBasePathGetter(remotes, path, obj) {
|
||||
var initialPath = obj.basePath || '';
|
||||
var basePath = String(obj.basePath) || '';
|
||||
|
||||
if (!/^https?:\/\//.test(basePath)) {
|
||||
remotes.before(path, function (ctx, next) {
|
||||
var headers = ctx.req.headers;
|
||||
var host = headers.Host || headers.host;
|
||||
|
||||
basePath = ctx.req.protocol + '://' + host + initialPath;
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
return setter(obj);
|
||||
|
||||
function getter() {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
function setter(obj) {
|
||||
return Object.defineProperty(obj, 'basePath', {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
get: getter
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
|
||||
// Keys that are different between LDL and Swagger
|
||||
var KEY_TRANSLATIONS = {
|
||||
// LDL : Swagger
|
||||
'doc': 'description',
|
||||
'default': 'defaultValue',
|
||||
'min': 'minimum',
|
||||
'max': 'maximum'
|
||||
};
|
||||
|
||||
/**
|
||||
* Export the modelHelper singleton.
|
||||
*/
|
||||
var modelHelper = module.exports = {
|
||||
/**
|
||||
* Given a class (from remotes.classes()), generate a model definition.
|
||||
* This is used to generate the schema at the top of many endpoints.
|
||||
* @param {Class} class Remote class.
|
||||
* @return {Object} Associated model definition.
|
||||
*/
|
||||
generateModelDefinition: function generateModelDefinition(aClass) {
|
||||
var def = aClass.ctor.definition;
|
||||
var name = def.name;
|
||||
|
||||
var required = [];
|
||||
|
||||
// Iterate through each property in the model definition.
|
||||
// Types are defined as constructors (e.g. String, Date, etc.)
|
||||
// so we convert them to strings.
|
||||
Object.keys(def.properties).forEach(function(key) {
|
||||
var prop = def.properties[key];
|
||||
|
||||
// Eke a type out of the constructors we were passed.
|
||||
prop.type = getPropType(prop.type);
|
||||
if (prop.type === 'array') {
|
||||
prop.items = {
|
||||
type: getPropType(prop.type[0])
|
||||
};
|
||||
}
|
||||
|
||||
// Required props sit in a per-model array.
|
||||
if (prop.required || prop.id) {
|
||||
required.push(key);
|
||||
}
|
||||
|
||||
// Change mismatched keys.
|
||||
Object.keys(KEY_TRANSLATIONS).forEach(function(LDLKey){
|
||||
var val = prop[LDLKey];
|
||||
if (val) {
|
||||
// Should change in Swagger 2.0
|
||||
if (LDLKey === 'min' || LDLKey === 'max') {
|
||||
val = String(val);
|
||||
}
|
||||
prop[KEY_TRANSLATIONS[LDLKey]] = val;
|
||||
}
|
||||
delete prop[LDLKey];
|
||||
});
|
||||
});
|
||||
|
||||
var out = {};
|
||||
out[name] = {
|
||||
id: name,
|
||||
properties: def.properties,
|
||||
required: required
|
||||
};
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Given a propType (which may be a function, string, or array),
|
||||
* get a string type.
|
||||
* @param {*} propType Prop type description.
|
||||
* @return {String} Prop type string.
|
||||
*/
|
||||
function getPropType(propType) {
|
||||
if (typeof propType === "function") {
|
||||
propType = propType.name.toLowerCase();
|
||||
} else if(Array.isArray(propType)) {
|
||||
propType = 'array';
|
||||
}
|
||||
return propType;
|
||||
}
|
|
@ -0,0 +1,221 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
var debug = require('debug')('loopback-explorer:routeHelpers');
|
||||
var _cloneDeep = require('lodash.clonedeep');
|
||||
|
||||
/**
|
||||
* Export the routeHelper singleton.
|
||||
*/
|
||||
var routeHelper = module.exports = {
|
||||
/**
|
||||
* Routes can be translated to API declaration 'operations',
|
||||
* but they need a little massaging first. The `accepts` and
|
||||
* `returns` declarations need some basic conversions to be compatible.
|
||||
*
|
||||
* This method will convert the route and add it to the doc.
|
||||
* @param {Class} class All remoting classes used by the API.
|
||||
* @param {Route} route Strong Remoting Route object.
|
||||
* @param {Class} classDef Strong Remoting class.
|
||||
* @param {Object} doc The class's backing API declaration doc.
|
||||
*/
|
||||
addRouteToAPIDeclaration: function (route, classDef, doc) {
|
||||
|
||||
// Some fixes to returns/accepts
|
||||
var processedRoute = doRouteParameterHacks(route, classDef);
|
||||
|
||||
// Add the api to the spec. If the path already exists, add as another operation
|
||||
// under the api; otherwise add a new api.
|
||||
addRouteToDoc(processedRoute, 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),
|
||||
* add it as a new operation under that API description.
|
||||
*
|
||||
* @param {Route} route Route.
|
||||
* @param {Object} doc Current document.
|
||||
*/
|
||||
function addRouteToDoc(route, doc) {
|
||||
var api = routeToAPI(route);
|
||||
var matchingAPIs = doc.apis.filter(function(existingAPI) {
|
||||
return existingAPI.path === api.path;
|
||||
});
|
||||
if (matchingAPIs.length) {
|
||||
matchingAPIs[0].operations.push(api.operations[0]);
|
||||
} else {
|
||||
doc.apis.push(api);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a route.
|
||||
* Contains some hacks to fix some incompatibilities in the accepts and returns
|
||||
* descriptions.
|
||||
* @param {Route} route A Route.
|
||||
* @param {Class} classDef The backing strong remoting class.
|
||||
*/
|
||||
function doRouteParameterHacks(route, classDef) {
|
||||
// Don't modify the existing route as some pieces (such as `returns`) may be shared between routes.
|
||||
route = _cloneDeep(route);
|
||||
|
||||
var split = route.method.split('.');
|
||||
if (classDef && classDef.sharedCtor && classDef.sharedCtor.accepts && split.length > 2 /* HACK */) {
|
||||
route.accepts = (route.accepts || []).concat(classDef.sharedCtor.accepts);
|
||||
}
|
||||
|
||||
// Filter out parameters that are generated from the incoming request or body,
|
||||
// or generated by functions that use those resources.
|
||||
route.accepts = (route.accepts || []).filter(function(arg){
|
||||
if (!arg.http) return true;
|
||||
// Don't show derived arguments.
|
||||
if (typeof arg.http === 'function') return false;
|
||||
// Don't show arguments set to the incoming http request.
|
||||
// Please note that body needs to be shown, such as User.create().
|
||||
if (arg.http.source === 'req') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// HACK: makes autogenerated REST routes return the correct model name.
|
||||
var returns = route.returns && route.returns[0];
|
||||
if (returns && returns.arg === 'data') {
|
||||
if (returns.type === 'object') {
|
||||
returns.type = classDef.name;
|
||||
} else if (returns.type === 'array') {
|
||||
returns.type = 'array';
|
||||
returns.items = {
|
||||
'$ref': classDef.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
debug('route %j', route);
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from an sl-remoting-formatted "Route" description to a
|
||||
* Swagger-formatted "API" description.
|
||||
* See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#523-operation-object
|
||||
*/
|
||||
|
||||
function routeToAPI(route) {
|
||||
var returnDesc = route.returns && route.returns[0];
|
||||
|
||||
return {
|
||||
path: convertPathFragments(route.path),
|
||||
operations: [{
|
||||
method: convertVerb(route.verb),
|
||||
nickname: route.method.replace(/\./g, '_'), // [rfeng] Swagger UI doesn't escape '.' for jQuery selector
|
||||
type: returnDesc ? returnDesc.model || prepareDataType(returnDesc.type) : 'void',
|
||||
items: returnDesc ? returnDesc.items : '',
|
||||
parameters: route.accepts ? route.accepts.map(acceptToParameter(route)) : [],
|
||||
responseMessages: [], // TODO(schoon) - We don't have descriptions for this yet.
|
||||
summary: route.description, // TODO(schoon) - Excerpt?
|
||||
notes: '' // TODO(schoon) - `description` metadata?
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function convertPathFragments(path) {
|
||||
return path.split('/').map(function (fragment) {
|
||||
if (fragment.charAt(0) === ':') {
|
||||
return '{' + fragment.slice(1) + '}';
|
||||
}
|
||||
return fragment;
|
||||
}).join('/');
|
||||
}
|
||||
|
||||
function convertVerb(verb) {
|
||||
if (verb.toLowerCase() === 'all') {
|
||||
return 'POST';
|
||||
}
|
||||
|
||||
if (verb.toLowerCase() === 'del') {
|
||||
return 'DELETE';
|
||||
}
|
||||
|
||||
return verb.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A generator to convert from an sl-remoting-formatted "Accepts" description to
|
||||
* a Swagger-formatted "Parameter" description.
|
||||
*/
|
||||
|
||||
function acceptToParameter(route) {
|
||||
var type = 'form';
|
||||
|
||||
if (route.verb.toLowerCase() === 'get') {
|
||||
type = 'query';
|
||||
}
|
||||
|
||||
return function (accepts) {
|
||||
var name = accepts.name || accepts.arg;
|
||||
var paramType = type;
|
||||
|
||||
// TODO: Regex. This is leaky.
|
||||
if (route.path.indexOf(':' + name) !== -1) {
|
||||
paramType = 'path';
|
||||
}
|
||||
|
||||
// Check the http settings for the argument
|
||||
if(accepts.http && accepts.http.source) {
|
||||
paramType = accepts.http.source;
|
||||
}
|
||||
|
||||
var out = {
|
||||
paramType: paramType || type,
|
||||
name: name,
|
||||
description: accepts.description,
|
||||
type: accepts.model || prepareDataType(accepts.type),
|
||||
required: !!accepts.required,
|
||||
allowMultiple: false
|
||||
};
|
||||
|
||||
// HACK: Derive the type from model
|
||||
if(out.name === 'data' && out.type === 'object') {
|
||||
out.type = route.method.split('.')[0];
|
||||
}
|
||||
|
||||
if (out.type === 'array') {
|
||||
out.items = {
|
||||
type: prepareDataType(accepts.type[0])
|
||||
};
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from an sl-remoting data type to a Swagger dataType.
|
||||
*/
|
||||
|
||||
function prepareDataType(type) {
|
||||
if (!type) {
|
||||
return 'void';
|
||||
}
|
||||
|
||||
if(Array.isArray(type)) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
// TODO(schoon) - Add support for complex dataTypes, "models", etc.
|
||||
switch (type) {
|
||||
case 'buffer':
|
||||
return 'byte';
|
||||
case 'date':
|
||||
return 'Date';
|
||||
case 'number':
|
||||
return 'double';
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
388
lib/swagger.js
388
lib/swagger.js
|
@ -1,3 +1,4 @@
|
|||
'use strict';
|
||||
/**
|
||||
* Expose the `Swagger` plugin.
|
||||
*/
|
||||
|
@ -8,376 +9,123 @@ module.exports = Swagger;
|
|||
*/
|
||||
var Remoting = require('strong-remoting');
|
||||
var debug = require('debug')('loopback-explorer:swagger');
|
||||
var path = require('path');
|
||||
var _defaults = require('lodash.defaults');
|
||||
var classHelper = require('./class-helper');
|
||||
var modelHelper = require('./model-helper');
|
||||
var routeHelper = require('./route-helper');
|
||||
|
||||
/**
|
||||
* Create a remotable Swagger module for plugging into `RemoteObjects`.
|
||||
*/
|
||||
function Swagger(remotes, options) {
|
||||
// Unfold options.
|
||||
var _options = options || {};
|
||||
var name = _options.name || 'swagger';
|
||||
var version = _options.version;
|
||||
var basePath = _options.basePath;
|
||||
function Swagger(remotes, opts) {
|
||||
opts = _defaults({}, opts, {
|
||||
name: 'swagger',
|
||||
swaggerVersion: '1.2',
|
||||
resourcePath: 'resources',
|
||||
version: getVersion(),
|
||||
basePath: '/'
|
||||
});
|
||||
|
||||
// We need a temporary REST adapter to discover our available routes.
|
||||
var adapter = remotes.handler('rest').adapter;
|
||||
var routes = adapter.allRoutes();
|
||||
var classes = remotes.classes();
|
||||
|
||||
// Create a new Remoting instance to host the swagger docs.
|
||||
var extension = {};
|
||||
var helper = Remoting.extend(extension);
|
||||
|
||||
// These are the docs we will be sending from the /swagger endpoints.
|
||||
var resourceDoc = generateResourceDoc(opts);
|
||||
var apiDocs = {};
|
||||
var resourceDoc = {
|
||||
apiVersion: version,
|
||||
swaggerVersion: '1.2',
|
||||
basePath: basePath,
|
||||
apis: []
|
||||
};
|
||||
|
||||
// A class is an endpoint root; e.g. /users, /products, and so on.
|
||||
classes.forEach(function (item) {
|
||||
resourceDoc.apis.push({
|
||||
path: '/' + name + item.http.path,
|
||||
description: item.ctor.sharedCtor && item.ctor.sharedCtor.description
|
||||
});
|
||||
classes.forEach(function (aClass) {
|
||||
apiDocs[aClass.name] = classHelper.generateAPIDoc(aClass, opts);
|
||||
resourceDoc.apis.push(classHelper.generateResourceDocAPIEntry(aClass));
|
||||
|
||||
apiDocs[item.name] = {
|
||||
apiVersion: resourceDoc.apiVersion,
|
||||
swaggerVersion: resourceDoc.swaggerVersion,
|
||||
basePath: resourceDoc.basePath,
|
||||
apis: [],
|
||||
models: generateModelDefinition(item)
|
||||
};
|
||||
|
||||
helper.method(api, {
|
||||
path: item.name,
|
||||
http: { path: item.http.path },
|
||||
returns: { type: 'object', root: true }
|
||||
});
|
||||
function api(callback) {
|
||||
callback(null, apiDocs[item.name]);
|
||||
}
|
||||
addDynamicBasePathGetter(remotes, name + '.' + item.name, apiDocs[item.name]);
|
||||
// Add the getter for this doc.
|
||||
var docPath = path.join(opts.resourcePath, aClass.http.path);
|
||||
addRoute(helper, apiDocs[aClass.name], docPath);
|
||||
classHelper.addDynamicBasePathGetter(remotes, opts.name + '.' + docPath, apiDocs[aClass.name]);
|
||||
});
|
||||
|
||||
// A route is an endpoint, such as /users/findOne.
|
||||
routes.forEach(function (route) {
|
||||
var split = route.method.split('.');
|
||||
var doc = apiDocs[split[0]];
|
||||
var classDef;
|
||||
|
||||
|
||||
routes.forEach(function(route) {
|
||||
// Get the API doc matching this class name.
|
||||
var className = route.method.split('.')[0];
|
||||
var doc = apiDocs[className];
|
||||
if (!doc) {
|
||||
console.error('Route exists with no class: %j', route);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the class definition matching this route.
|
||||
classDef = classes.filter(function (item) {
|
||||
return item.name === split[0];
|
||||
var classDef = classes.filter(function (item) {
|
||||
return item.name === className;
|
||||
})[0];
|
||||
|
||||
if (classDef && classDef.sharedCtor && classDef.sharedCtor.accepts && split.length > 2 /* HACK */) {
|
||||
route.accepts = (route.accepts || []).concat(classDef.sharedCtor.accepts);
|
||||
}
|
||||
|
||||
// Filter out parameters that are generated from the incoming request or body,
|
||||
// or generated by functions that use those resources.
|
||||
route.accepts = (route.accepts || []).filter(function(arg){
|
||||
if (!arg.http) return true;
|
||||
// Don't show derived arguments.
|
||||
if (typeof arg.http === 'function') return false;
|
||||
// Don't show arguments set to the incoming http request
|
||||
// Please note that body needs to be shown, such as User.create()
|
||||
if (arg.http.source === 'req') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// HACK: makes autogenerated REST routes return the correct model name.
|
||||
var returns = route.returns && route.returns[0];
|
||||
if(typeof returns === 'object') {
|
||||
// Clone the object as it's shared by multiple models
|
||||
var originalReturns = returns;
|
||||
returns = {};
|
||||
for(var p in originalReturns) {
|
||||
returns[p] = originalReturns[p];
|
||||
}
|
||||
route.returns[0] = returns;
|
||||
}
|
||||
if (returns && returns.arg === 'data') {
|
||||
if (returns.type === 'object') {
|
||||
returns.type = classDef.name;
|
||||
} else if (returns.type === 'array') {
|
||||
returns.type = 'array';
|
||||
returns.items = {
|
||||
'$ref': classDef.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
debug('route %j', route);
|
||||
doc.apis.push(routeToAPI(route));
|
||||
routeHelper.addRouteToAPIDeclaration(route, classDef, doc);
|
||||
});
|
||||
|
||||
/**
|
||||
* The topmost Swagger resource is a description of all (non-Swagger) resources
|
||||
* available on the system, and where to find more information about them.
|
||||
*/
|
||||
helper.method(resources, {
|
||||
returns: [{ type: 'object', root: true }]
|
||||
});
|
||||
function resources(callback) {
|
||||
callback(null, resourceDoc);
|
||||
}
|
||||
addDynamicBasePathGetter(remotes, name + '.resources', resourceDoc);
|
||||
addRoute(helper, resourceDoc, opts.resourcePath);
|
||||
|
||||
// Bind all the above routes to the endpoint at /#{name}.
|
||||
remotes.exports[opts.name] = extension;
|
||||
|
||||
remotes.exports[name] = extension;
|
||||
return extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* There's a few forces at play that require this "hack". The Swagger spec
|
||||
* requires a `basePath` to be set at various points in the API/Resource
|
||||
* descriptions. However, we can't guarantee this path is either reachable or
|
||||
* desirable if it's set as a part of the options.
|
||||
*
|
||||
* The simplest way around this is to reflect the value of the `Host` HTTP
|
||||
* header as the `basePath`. Because we pre-build the Swagger data, we don't
|
||||
* know that header at the time the data is built. Hence, the getter function.
|
||||
* We can use a `before` hook to pluck the `Host`, then the getter kicks in to
|
||||
* return that path as the `basePath` during JSON serialization.
|
||||
*
|
||||
* @param {SharedClassCollection} remotes The Collection to register a `before`
|
||||
* hook on.
|
||||
* @param {String} path The full path of the route to register
|
||||
* a `before` hook on.
|
||||
* @param {Object} obj The Object to install the `basePath`
|
||||
* getter on.
|
||||
* Add a route to this remoting extension.
|
||||
* @param {Remote} helper Remoting extension.
|
||||
* @param {Object} doc Doc to serve.
|
||||
* @param {String} path Path from which to serve the doc.
|
||||
*/
|
||||
function addDynamicBasePathGetter(remotes, path, obj) {
|
||||
var initialPath = obj.basePath || '/';
|
||||
var basePath = String(obj.basePath) || '';
|
||||
|
||||
if (!/^https?:\/\//.test(basePath)) {
|
||||
remotes.before(path, function (ctx, next) {
|
||||
var headers = ctx.req.headers;
|
||||
var host = headers.Host || headers.host;
|
||||
|
||||
basePath = ctx.req.protocol + '://' + host + initialPath;
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
return setter(obj);
|
||||
|
||||
function getter() {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
function setter(obj) {
|
||||
return Object.defineProperty(obj, 'basePath', {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
get: getter
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a class (from remotes.classes()), generate a model definition.
|
||||
* This is used to generate the schema at the top of many endpoints.
|
||||
* @param {Class} class Remote class.
|
||||
* @return {Object} Associated model definition.
|
||||
*/
|
||||
function generateModelDefinition(aClass) {
|
||||
var def = aClass.ctor.definition;
|
||||
var name = def.name;
|
||||
|
||||
var required = [];
|
||||
// Keys that are different between LDL and Swagger
|
||||
var keyTranslations = {
|
||||
// LDL : Swagger
|
||||
'doc': 'description',
|
||||
'default': 'defaultValue',
|
||||
'min': 'minimum',
|
||||
'max': 'maximum'
|
||||
};
|
||||
|
||||
// Iterate through each property in the model definition.
|
||||
// Types are defined as constructors (e.g. String, Date, etc.)
|
||||
// so we convert them to strings.
|
||||
Object.keys(def.properties).forEach(function(key) {
|
||||
var prop = def.properties[key];
|
||||
|
||||
// Eke a type out of the constructors we were passed.
|
||||
prop.type = getPropType(prop.type);
|
||||
if (prop.type === 'array') {
|
||||
prop.items = {
|
||||
type: getPropType(prop.type[0])
|
||||
};
|
||||
}
|
||||
|
||||
// Required props sit in a per-model array.
|
||||
if (prop.required || prop.id) {
|
||||
required.push(key);
|
||||
}
|
||||
|
||||
// Change mismatched keys.
|
||||
Object.keys(keyTranslations).forEach(function(LDLKey){
|
||||
var val = prop[LDLKey];
|
||||
if (val) {
|
||||
// Should change in Swagger 2.0
|
||||
if (LDLKey === 'min' || LDLKey === 'max') {
|
||||
val = String(val);
|
||||
}
|
||||
prop[keyTranslations[LDLKey]] = val;
|
||||
}
|
||||
delete prop[LDLKey];
|
||||
});
|
||||
function addRoute(helper, doc, path) {
|
||||
helper.method(getDoc, {
|
||||
path: path,
|
||||
returns: { type: 'object', root: true }
|
||||
});
|
||||
|
||||
var out = {};
|
||||
out[name] = {
|
||||
id: name,
|
||||
properties: def.properties,
|
||||
required: required
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a propType (which may be a function, string, or array),
|
||||
* get a string type.
|
||||
* @param {*} propType Prop type description.
|
||||
* @return {String} Prop type string.
|
||||
*/
|
||||
function getPropType(propType) {
|
||||
if (typeof propType === "function") {
|
||||
propType = propType.name.toLowerCase();
|
||||
} else if(Array.isArray(propType)) {
|
||||
propType = 'array';
|
||||
function getDoc(callback) {
|
||||
callback(null, doc);
|
||||
}
|
||||
return propType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from an sl-remoting-formatted "Route" description to a
|
||||
* Swagger-formatted "API" description.
|
||||
* Generate a top-level resource doc. This is the entry point for swagger UI
|
||||
* and lists all of the available APIs.
|
||||
* @param {Object} opts Swagger options.
|
||||
* @return {Object} Resource doc.
|
||||
*/
|
||||
|
||||
function routeToAPI(route) {
|
||||
var returnDesc = route.returns && route.returns[0];
|
||||
|
||||
function generateResourceDoc(opts) {
|
||||
return {
|
||||
path: convertPathFragments(route.path),
|
||||
operations: [{
|
||||
method: convertVerb(route.verb),
|
||||
nickname: route.method.replace(/\./g, '_'), // [rfeng] Swagger UI doesn't escape '.' for jQuery selector
|
||||
type: returnDesc ? returnDesc.model || prepareDataType(returnDesc.type) : 'void',
|
||||
items: returnDesc ? returnDesc.items : '',
|
||||
parameters: route.accepts ? route.accepts.map(acceptToParameter(route)) : [],
|
||||
responseMessages: [], // TODO(schoon) - We don't have descriptions for this yet.
|
||||
summary: route.description, // TODO(schoon) - Excerpt?
|
||||
notes: '' // TODO(schoon) - `description` metadata?
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function convertPathFragments(path) {
|
||||
return path.split('/').map(function (fragment) {
|
||||
if (fragment.charAt(0) === ':') {
|
||||
return '{' + fragment.slice(1) + '}';
|
||||
}
|
||||
return fragment;
|
||||
}).join('/');
|
||||
}
|
||||
|
||||
function convertVerb(verb) {
|
||||
if (verb.toLowerCase() === 'all') {
|
||||
return 'POST';
|
||||
}
|
||||
|
||||
if (verb.toLowerCase() === 'del') {
|
||||
return 'DELETE';
|
||||
}
|
||||
|
||||
return verb.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A generator to convert from an sl-remoting-formatted "Accepts" description to
|
||||
* a Swagger-formatted "Parameter" description.
|
||||
*/
|
||||
|
||||
function acceptToParameter(route) {
|
||||
var type = 'query'; // Default to query
|
||||
|
||||
if (route.verb.toLowerCase() === 'get') {
|
||||
type = 'query';
|
||||
}
|
||||
|
||||
return function (accepts) {
|
||||
var name = accepts.name || accepts.arg;
|
||||
var paramType = type;
|
||||
|
||||
// TODO: Regex. This is leaky.
|
||||
if (route.path.indexOf(':' + name) !== -1) {
|
||||
paramType = 'path';
|
||||
}
|
||||
|
||||
// Check the http settings for the argument
|
||||
if(accepts.http && accepts.http.source) {
|
||||
paramType = accepts.http.source;
|
||||
}
|
||||
|
||||
var out = {
|
||||
paramType: paramType || type,
|
||||
name: name,
|
||||
description: accepts.description,
|
||||
type: accepts.model || prepareDataType(accepts.type),
|
||||
required: !!accepts.required,
|
||||
allowMultiple: false
|
||||
};
|
||||
|
||||
// HACK: Derive the type from model
|
||||
if(out.name === 'data' && out.type === 'object') {
|
||||
out.type = route.method.split('.')[0];
|
||||
}
|
||||
|
||||
if (out.type === 'array') {
|
||||
out.items = {
|
||||
type: prepareDataType(accepts.type[0])
|
||||
};
|
||||
}
|
||||
|
||||
return out;
|
||||
swaggerVersion: opts.swaggerVersion,
|
||||
apiVersion: opts.version,
|
||||
apis: [],
|
||||
// See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#513-info-object
|
||||
info: opts.apiInfo
|
||||
// TODO Authorizations
|
||||
// https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#514-authorizations-object
|
||||
// TODO Produces/Consumes
|
||||
// https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#52-api-declaration
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from an sl-remoting data type to a Swagger dataType.
|
||||
* Attempt to get the current API version from package.json.
|
||||
* @return {String} API Version.
|
||||
*/
|
||||
|
||||
function prepareDataType(type) {
|
||||
if (!type) {
|
||||
return 'void';
|
||||
function getVersion() {
|
||||
var version;
|
||||
try {
|
||||
version = require(path.join(process.cwd(), 'package.json')).version;
|
||||
} catch(e) {
|
||||
version = '';
|
||||
}
|
||||
|
||||
if(Array.isArray(type)) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
// TODO(schoon) - Add support for complex dataTypes, "models", etc.
|
||||
switch (type) {
|
||||
case 'buffer':
|
||||
return 'byte';
|
||||
case 'date':
|
||||
return 'Date';
|
||||
case 'number':
|
||||
return 'double';
|
||||
}
|
||||
|
||||
return type;
|
||||
return version;
|
||||
}
|
||||
|
|
|
@ -36,6 +36,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"swagger-ui": "~2.0.17",
|
||||
"debug": "~1.0.2"
|
||||
"debug": "~1.0.2",
|
||||
"lodash.clonedeep": "^2.4.1",
|
||||
"lodash.defaults": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"node": true,
|
||||
"camelcase" : true,
|
||||
"eqnull" : true,
|
||||
"indent": 2,
|
||||
"undef": true,
|
||||
"quotmark": "single",
|
||||
"maxlen": 80,
|
||||
"trailing": true,
|
||||
"newcap": true,
|
||||
"nonew": true,
|
||||
"undef": false,
|
||||
"globals" : {
|
||||
/* MOCHA */
|
||||
"describe" : false,
|
||||
"it" : false,
|
||||
"before" : false,
|
||||
"beforeEach" : false,
|
||||
"after" : false,
|
||||
"afterEach" : false
|
||||
}
|
||||
}
|
|
@ -24,7 +24,8 @@ describe('explorer', function() {
|
|||
.end(function(err, res) {
|
||||
if (err) throw err;
|
||||
|
||||
assert(!!~res.text.indexOf('<title>StrongLoop API Explorer</title>'), 'text does not contain expected string');
|
||||
assert(!!~res.text.indexOf('<title>StrongLoop API Explorer</title>'),
|
||||
'text does not contain expected string');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,9 +19,8 @@
|
|||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
var extend = require('util')._extend;
|
||||
var inherits = require('util').inherits;
|
||||
var url = require('url');
|
||||
var path = require('path');
|
||||
var loopback = require('loopback');
|
||||
|
||||
var RemoteObjects = require('strong-remoting');
|
||||
|
@ -31,31 +30,40 @@ var request = require('supertest');
|
|||
var expect = require('chai').expect;
|
||||
|
||||
describe('swagger definition', function() {
|
||||
var objects;
|
||||
var remotes;
|
||||
var app;
|
||||
|
||||
// setup
|
||||
beforeEach(function(){
|
||||
objects = RemoteObjects.create();
|
||||
remotes = objects.exports;
|
||||
beforeEach(function() {
|
||||
app = createLoopbackAppWithModel();
|
||||
});
|
||||
|
||||
describe('basePath', function() {
|
||||
it('is "http://{host}/" by default', function(done) {
|
||||
swagger(objects);
|
||||
// No basepath on resource doc in 1.2
|
||||
it('no longer exists on resource doc', function(done) {
|
||||
swagger(app.remotes());
|
||||
|
||||
var getReq = getSwaggerResources();
|
||||
getReq.end(function(err, res) {
|
||||
if (err) return done(err);
|
||||
expect(res.body.basePath).to.equal(url.resolve(getReq.url, '/'));
|
||||
done();
|
||||
});
|
||||
if (err) return done(err);
|
||||
expect(res.body.basePath).to.equal(undefined);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('is "http://{host}/" by default', function(done) {
|
||||
swagger(app.remotes());
|
||||
|
||||
var getReq = getAPIDeclaration('products');
|
||||
getReq.end(function(err, res) {
|
||||
if (err) return done(err);
|
||||
expect(res.body.basePath).to.equal(url.resolve(getReq.url, '/'));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('is "http://{host}/{basePath}" when basePath is a path', function(done){
|
||||
swagger(objects, { basePath: '/api-root'});
|
||||
swagger(app.remotes(), { basePath: '/api-root'});
|
||||
|
||||
var getReq = getSwaggerResources();
|
||||
var getReq = getAPIDeclaration('products');
|
||||
getReq.end(function(err, res) {
|
||||
if (err) return done(err);
|
||||
var apiRoot = url.resolve(getReq.url, '/api-root');
|
||||
|
@ -67,9 +75,9 @@ describe('swagger definition', function() {
|
|||
it('is custom URL when basePath is a http(s) URL', function(done) {
|
||||
var apiUrl = 'http://custom-api-url/';
|
||||
|
||||
swagger(objects, { basePath: apiUrl });
|
||||
swagger(app.remotes(), { basePath: apiUrl });
|
||||
|
||||
var getReq = getSwaggerResources();
|
||||
var getReq = getAPIDeclaration('products');
|
||||
getReq.end(function(err, res) {
|
||||
if (err) return done(err);
|
||||
expect(res.body.basePath).to.equal(apiUrl);
|
||||
|
@ -80,7 +88,6 @@ describe('swagger definition', function() {
|
|||
|
||||
describe('Model definition attributes', function() {
|
||||
it('Properly defines basic attributes', function(done) {
|
||||
var app = createLoopbackAppWithModel();
|
||||
var extension = swagger(app.remotes(), {});
|
||||
getModelFromRemoting(extension, 'product', function(data) {
|
||||
expect(data.id).to.equal('product');
|
||||
|
@ -98,25 +105,16 @@ describe('swagger definition', function() {
|
|||
});
|
||||
});
|
||||
|
||||
function getSwaggerResources(restPath) {
|
||||
var app = createRestApiApp(restPath);
|
||||
var prefix = restPath || '';
|
||||
function getSwaggerResources(restPath, classPath) {
|
||||
return request(app)
|
||||
.get(prefix + '/swagger/resources')
|
||||
.get(path.join(restPath || '', '/swagger/resources', classPath || ''))
|
||||
.set('Accept', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
}
|
||||
|
||||
function createRestApiApp(restPath) {
|
||||
restPath = restPath || '/';
|
||||
|
||||
var app = loopback();
|
||||
app.use(restPath, function (req, res, next) {
|
||||
// create the handler for each request
|
||||
objects.handler('rest').apply(objects, arguments);
|
||||
});
|
||||
return app;
|
||||
function getAPIDeclaration(className) {
|
||||
return getSwaggerResources('', path.join('/', className));
|
||||
}
|
||||
|
||||
function createLoopbackAppWithModel() {
|
||||
|
@ -129,12 +127,14 @@ describe('swagger definition', function() {
|
|||
});
|
||||
Product.attachTo(loopback.memory());
|
||||
app.model(Product);
|
||||
|
||||
app.use(loopback.rest());
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function getModelFromRemoting(extension, modelName, cb) {
|
||||
extension[modelName](function(err, data) {
|
||||
extension['resources/' + modelName + 's'](function(err, data) {
|
||||
cb(data.models[modelName]);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue