/** * 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; }