Refactor route-helper & add tests.
- Uses model-helper to parse types for swagger. - Separated returns & accepts hacks. - Documentation fixes - TODO add param regex
This commit is contained in:
parent
77f01670de
commit
3ce35e1431
|
@ -7,223 +7,233 @@
|
||||||
var debug = require('debug')('loopback-explorer:routeHelpers');
|
var debug = require('debug')('loopback-explorer:routeHelpers');
|
||||||
var _cloneDeep = require('lodash.clonedeep');
|
var _cloneDeep = require('lodash.clonedeep');
|
||||||
var translateKeys = require('./translate-keys');
|
var translateKeys = require('./translate-keys');
|
||||||
|
var modelHelper = require('./model-helper');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export the routeHelper singleton.
|
* Export the routeHelper singleton.
|
||||||
*/
|
*/
|
||||||
var routeHelper = module.exports = {
|
var routeHelper = module.exports = {
|
||||||
/**
|
/**
|
||||||
|
* Given a route, generate an API description and add it to the doc.
|
||||||
|
* If a route shares a path with another route (same path, different verb),
|
||||||
|
* add it as a new operation under that API description.
|
||||||
|
*
|
||||||
* Routes can be translated to API declaration 'operations',
|
* 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
|
||||||
* `returns` declarations need some basic conversions to be compatible.
|
* `returns` declarations need some basic conversions to be compatible.
|
||||||
*
|
*
|
||||||
* This method will convert the route and add it to the doc.
|
* 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 {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 {Object} doc The class's backing API declaration doc.
|
||||||
*/
|
*/
|
||||||
addRouteToAPIDeclaration: function (route, classDef, doc) {
|
addRouteToAPIDeclaration: function (route, classDef, doc) {
|
||||||
|
var api = routeHelper.routeToAPIDoc(route, classDef);
|
||||||
// Some fixes to returns/accepts
|
var matchingAPIs = doc.apis.filter(function(existingAPI) {
|
||||||
var processedRoute = doRouteParameterHacks(route, classDef);
|
return existingAPI.path === api.path;
|
||||||
|
});
|
||||||
|
if (matchingAPIs.length) {
|
||||||
|
matchingAPIs[0].operations.push(api.operations[0]);
|
||||||
|
} else {
|
||||||
|
doc.apis.push(api);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Add the api to the spec. If the path already exists, add as another operation
|
/**
|
||||||
// under the api; otherwise add a new api.
|
* Massage route.accepts.
|
||||||
addRouteToDoc(processedRoute, doc);
|
* @param {Object} route Strong Remoting Route object.
|
||||||
|
* @param {Class} classDef Strong Remoting class.
|
||||||
|
* @return {Object} Modified Route object.
|
||||||
|
*/
|
||||||
|
hackAcceptsDefinition: function hackAcceptsDefinition(route, classDef) {
|
||||||
|
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 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Translate LDL keys to Swagger keys.
|
||||||
|
route.accepts = (route.accepts || []).map(translateKeys);
|
||||||
|
|
||||||
|
return route;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Massage route.returns.
|
||||||
|
* @param {Object} route Strong Remoting Route object.
|
||||||
|
* @param {Class} classDef Strong Remoting class.
|
||||||
|
* @return {Object} Modified Route object.
|
||||||
|
*/
|
||||||
|
hackReturnsDefinition: function hackReturnsDefinition(route, classDef) {
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate LDL keys to Swagger keys.
|
||||||
|
route.returns = (route.returns || []).map(translateKeys);
|
||||||
|
|
||||||
|
// Convert `returns` into a single object for later conversion into an
|
||||||
|
// operation object.
|
||||||
|
if (route.returns && route.returns.length > 1) {
|
||||||
|
// TODO ad-hoc model definition in the case of multiple return values.
|
||||||
|
route.returns = {model: 'object'};
|
||||||
|
} else {
|
||||||
|
route.returns = route.returns[0] || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
routeToAPIDoc: function routeToAPIDoc(route, classDef) {
|
||||||
|
var returnDesc;
|
||||||
|
|
||||||
|
// Don't modify the existing route as some pieces (such as `returns`)
|
||||||
|
// may be shared between routes.
|
||||||
|
route = _cloneDeep(route);
|
||||||
|
|
||||||
|
// Some parameters need to be altered; eventually most of this should
|
||||||
|
// be removed.
|
||||||
|
route = routeHelper.hackAcceptsDefinition(route, classDef);
|
||||||
|
route = routeHelper.hackReturnsDefinition(route, classDef);
|
||||||
|
|
||||||
|
debug('route %j', route);
|
||||||
|
|
||||||
|
var apiDoc = {
|
||||||
|
path: routeHelper.convertPathFragments(route.path),
|
||||||
|
operations: [{
|
||||||
|
method: routeHelper.convertVerb(route.verb),
|
||||||
|
// [rfeng] Swagger UI doesn't escape '.' for jQuery selector
|
||||||
|
nickname: route.method.replace(/\./g, '_'),
|
||||||
|
// Per the spec:
|
||||||
|
// https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#523-operation-object
|
||||||
|
// This is the only object that may have a type of 'void'.
|
||||||
|
type: route.returns.model || route.returns.type || 'void',
|
||||||
|
parameters: route.accepts.map(routeHelper.acceptToParameter(route)),
|
||||||
|
// TODO(schoon) - We don't have descriptions for this yet.
|
||||||
|
responseMessages: [],
|
||||||
|
summary: route.description, // TODO(schoon) - Excerpt?
|
||||||
|
notes: '' // TODO(schoon) - `description` metadata?
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
// Convert types and return.
|
||||||
|
return routeHelper.extendWithType(apiDoc);
|
||||||
|
},
|
||||||
|
|
||||||
|
convertPathFragments: function convertPathFragments(path) {
|
||||||
|
return path.split('/').map(function (fragment) {
|
||||||
|
if (fragment.charAt(0) === ':') {
|
||||||
|
return '{' + fragment.slice(1) + '}';
|
||||||
|
}
|
||||||
|
return fragment;
|
||||||
|
}).join('/');
|
||||||
|
},
|
||||||
|
|
||||||
|
convertVerb: function convertVerb(verb) {
|
||||||
|
if (verb.toLowerCase() === 'all') {
|
||||||
|
return 'POST';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb.toLowerCase() === 'del') {
|
||||||
|
return 'DELETE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return verb.toUpperCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generator to convert from an sl-remoting-formatted "Accepts" description
|
||||||
|
* to a Swagger-formatted "Parameter" description.
|
||||||
|
*/
|
||||||
|
acceptToParameter: function acceptToParameter(route) {
|
||||||
|
var type = 'form';
|
||||||
|
|
||||||
|
if (route.verb.toLowerCase() === 'get') {
|
||||||
|
type = 'query';
|
||||||
|
}
|
||||||
|
|
||||||
|
return function (accepts) {
|
||||||
|
var name = accepts.name || accepts.arg;
|
||||||
|
var paramType = type;
|
||||||
|
|
||||||
|
// TODO: Regex. This is leaky.
|
||||||
|
if (route.path.indexOf(':' + name) !== -1) {
|
||||||
|
paramType = 'path';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the http settings for the argument
|
||||||
|
if(accepts.http && accepts.http.source) {
|
||||||
|
paramType = accepts.http.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
var out = {
|
||||||
|
paramType: paramType || type,
|
||||||
|
name: name,
|
||||||
|
description: accepts.description,
|
||||||
|
type: accepts.type,
|
||||||
|
required: !!accepts.required,
|
||||||
|
defaultValue: accepts.defaultValue,
|
||||||
|
minimum: accepts.minimum,
|
||||||
|
maximum: accepts.maximum,
|
||||||
|
allowMultiple: false
|
||||||
|
};
|
||||||
|
|
||||||
|
out = routeHelper.extendWithType(out);
|
||||||
|
|
||||||
|
// HACK: Derive the type from model
|
||||||
|
if(out.name === 'data' && out.type === 'object') {
|
||||||
|
out.type = route.method.split('.')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends an Operation Object or Parameter object with
|
||||||
|
* a proper Swagger type and optional `format` and `items` fields.
|
||||||
|
* Does not modify original object.
|
||||||
|
* @param {Object} obj Object to extend.
|
||||||
|
* @return {Object} Extended object.
|
||||||
|
*/
|
||||||
|
extendWithType: function extendWithType(obj) {
|
||||||
|
obj = _cloneDeep(obj);
|
||||||
|
|
||||||
|
// Format the `type` property using our LDL converter.
|
||||||
|
var typeDesc = modelHelper
|
||||||
|
.LDLPropToSwaggerDataType({type: obj.model || obj.type});
|
||||||
|
// The `typeDesc` may have additional attributes, such as
|
||||||
|
// `format` for non-primitive types.
|
||||||
|
Object.keys(typeDesc).forEach(function(key){
|
||||||
|
obj[key] = typeDesc[key];
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Translate LDL keys to Swagger keys.
|
|
||||||
route.accepts = (route.accepts || []).map(translateKeys);
|
|
||||||
route.returns = (route.returns || []).map(translateKeys);
|
|
||||||
|
|
||||||
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,
|
|
||||||
defaultValue: accepts.defaultValue,
|
|
||||||
minimum: accepts.minimum,
|
|
||||||
maximum: accepts.maximum,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var routeHelper = require('../lib/route-helper');
|
||||||
|
var expect = require('chai').expect;
|
||||||
|
var _defaults = require('lodash.defaults');
|
||||||
|
|
||||||
|
describe('route-helper', function() {
|
||||||
|
// TODO
|
||||||
|
it('returns "object" when a route has multiple return values', function() {
|
||||||
|
var doc = createAPIDoc({
|
||||||
|
returns: [
|
||||||
|
{ arg: 'max', type: 'number' },
|
||||||
|
{ arg: 'min', type: 'number' },
|
||||||
|
{ arg: 'avg', type: 'number' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(doc.operations[0].type).to.equal('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts path params when they exist in the route name', function() {
|
||||||
|
var doc = createAPIDoc({
|
||||||
|
accepts: [
|
||||||
|
{arg: 'id', type: 'string'}
|
||||||
|
],
|
||||||
|
path: '/test/:id'
|
||||||
|
});
|
||||||
|
var paramDoc = doc.operations[0].parameters[0];
|
||||||
|
expect(paramDoc.paramType).to.equal('path');
|
||||||
|
expect(paramDoc.name).to.equal('id');
|
||||||
|
expect(paramDoc.required).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIXME need regex in routeHelper.acceptToParameter
|
||||||
|
xit('won\'t convert path params when they don\'t exist in the route name', function() {
|
||||||
|
var doc = createAPIDoc({
|
||||||
|
accepts: [
|
||||||
|
{arg: 'id', type: 'string'}
|
||||||
|
],
|
||||||
|
path: '/test/:identifier'
|
||||||
|
});
|
||||||
|
var paramDoc = doc.operations[0].parameters[0];
|
||||||
|
expect(paramDoc.paramType).to.equal('query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly coerces param types', function() {
|
||||||
|
var doc = createAPIDoc({
|
||||||
|
accepts: [
|
||||||
|
{arg: 'binaryData', type: 'buffer'}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
var paramDoc = doc.operations[0].parameters[0];
|
||||||
|
expect(paramDoc.paramType).to.equal('query');
|
||||||
|
expect(paramDoc.type).to.equal('string');
|
||||||
|
expect(paramDoc.format).to.equal('byte');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Easy wrapper around createRoute
|
||||||
|
function createAPIDoc(def) {
|
||||||
|
return routeHelper.routeToAPIDoc(_defaults(def, {
|
||||||
|
path: '/test',
|
||||||
|
verb: 'GET',
|
||||||
|
method: 'test.get'
|
||||||
|
}));
|
||||||
|
}
|
Loading…
Reference in New Issue