383 lines
10 KiB
JavaScript
383 lines
10 KiB
JavaScript
/**
|
|
* Expose the `Swagger` plugin.
|
|
*/
|
|
module.exports = Swagger;
|
|
|
|
/**
|
|
* Module dependencies.
|
|
*/
|
|
var Remoting = require('strong-remoting');
|
|
var debug = require('debug')('loopback-explorer:swagger');
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// 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();
|
|
|
|
var extension = {};
|
|
var helper = Remoting.extend(extension);
|
|
|
|
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
|
|
});
|
|
|
|
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]);
|
|
});
|
|
|
|
// 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;
|
|
|
|
|
|
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];
|
|
})[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 or body.
|
|
if (arg.http.source === 'req' || arg.http.source === 'body') 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));
|
|
});
|
|
|
|
/**
|
|
* 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);
|
|
|
|
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.
|
|
*/
|
|
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];
|
|
});
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Converts from an sl-remoting-formatted "Route" description to a
|
|
* Swagger-formatted "API" description.
|
|
*/
|
|
|
|
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;
|
|
}
|