Swagger 1.2 compatability. Moved strong-remoting/ext/swagger to this module.

Will now correctly return model schemas.

Moved swagger.js tests to this module.
This commit is contained in:
Samuel Reed 2014-07-04 17:09:03 -05:00 committed by Samuel Reed
parent 56003f0178
commit eb31787fbc
6 changed files with 498 additions and 9 deletions

View File

@ -3,7 +3,11 @@ var app = loopback();
var explorer = require('../');
var port = 3000;
var Product = loopback.Model.extend('product');
var Product = loopback.Model.extend('product', {
foo: {type: 'string', required: true},
bar: 'string',
aNum: {type: 'number', min: 1, max: 10, required: true, default: 5}
});
Product.attachTo(loopback.memory());
app.model(Product);

View File

@ -5,6 +5,7 @@ var path = require('path');
var extend = require('util')._extend;
var loopback = require('loopback');
var express = requireLoopbackDependency('express');
var swagger = require('./lib/swagger');
var fs = require('fs');
var SWAGGER_UI_ROOT = path.join(__dirname, 'node_modules', 'swagger-ui', 'dist');
var STATIC_ROOT = path.join(__dirname, 'public');
@ -20,9 +21,9 @@ module.exports = explorer;
function explorer(loopbackApplication, options) {
options = extend({}, options);
options.basePath = options.basePath || loopbackApplication.get('restApiRoot');
options.basePath = options.basePath || loopbackApplication.get('restApiRoot') || '';
loopbackApplication.docs(options);
swagger(loopbackApplication.remotes(), options);
var app = express();
@ -30,7 +31,7 @@ function explorer(loopbackApplication, options) {
app.get('/config.json', function(req, res) {
res.send({
url: (options.basePath || '') + '/swagger/resources'
url: options.basePath + '/swagger/resources'
});
});
// Allow specifying a static file root for swagger files. Any files in that folder

341
lib/swagger.js Normal file
View File

@ -0,0 +1,341 @@
/**
* 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;
}
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);
}
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.
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);
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 = aClass.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];
// Required props sit in a different array.
if (prop.required || prop.id) required.push(key);
// Eke a type out of the constructors we were passed.
if (typeof prop.type === "function") {
prop.type = prop.type.name.toLowerCase();
}
// 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;
}
/**
* 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
};
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;
}

View File

@ -7,7 +7,8 @@
"test": "mocha"
},
"peerDependencies": {
"loopback": "2.x || 1.x >=1.5"
"loopback": "2.x || 1.x >=1.5",
"strong-remoting": "2.x || 1.x >=1.5"
},
"repository": {
"type": "git",
@ -34,6 +35,7 @@
"url": "https://github.com/strongloop/loopback-explorer/blob/master/LICENSE"
},
"dependencies": {
"swagger-ui": "^2.0.17"
"swagger-ui": "~2.0.17",
"debug": "~1.0.2"
}
}

View File

@ -37,7 +37,7 @@ describe('explorer', function() {
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to
.have.property('discoveryUrl', '/swagger/resources');
.have.property('url', '/swagger/resources');
done();
});
});
@ -54,7 +54,7 @@ describe('explorer', function() {
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to
.have.property('discoveryUrl', '/api/swagger/resources');
.have.property('url', '/api/swagger/resources');
done();
});
});
@ -72,7 +72,7 @@ describe('explorer', function() {
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to
.have.property('discoveryUrl', '/rest-api-root/swagger/resources');
.have.property('url', '/rest-api-root/swagger/resources');
done();
});
});

141
test/swagger.test.js Normal file
View File

@ -0,0 +1,141 @@
/* Copyright (c) 2013 StrongLoop, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included
* in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
var extend = require('util')._extend;
var inherits = require('util').inherits;
var url = require('url');
var loopback = require('loopback');
var RemoteObjects = require('strong-remoting');
var swagger = require('../lib/swagger.js');
var request = require('supertest');
var expect = require('chai').expect;
describe('swagger definition', function() {
var objects;
var remotes;
// setup
beforeEach(function(){
objects = RemoteObjects.create();
remotes = objects.exports;
});
describe('basePath', function() {
it('is "http://{host}/" by default', function(done) {
swagger(objects);
var getReq = getSwaggerResources();
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'});
var getReq = getSwaggerResources();
getReq.end(function(err, res) {
if (err) return done(err);
var apiRoot = url.resolve(getReq.url, '/api-root');
expect(res.body.basePath).to.equal(apiRoot);
done();
});
});
it('is custom URL when basePath is a http(s) URL', function(done) {
var apiUrl = 'http://custom-api-url/';
swagger(objects, { basePath: apiUrl });
var getReq = getSwaggerResources();
getReq.end(function(err, res) {
if (err) return done(err);
expect(res.body.basePath).to.equal(apiUrl);
done();
});
});
});
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');
expect(data.required.sort()).to.eql(['id', '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();
});
});
});
function getSwaggerResources(restPath) {
var app = createRestApiApp(restPath);
var prefix = restPath || '';
return request(app)
.get(prefix + '/swagger/resources')
.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 createLoopbackAppWithModel() {
var app = loopback();
var Product = loopback.Model.extend('product', {
foo: {type: 'string', required: true},
bar: 'string',
aNum: {type: 'number', min: 1, max: 10, required: true, default: 5}
});
Product.attachTo(loopback.memory());
app.model(Product);
app.use(loopback.rest());
return app;
}
function getModelFromRemoting(extension, modelName, cb) {
extension[modelName](function(err, data) {
cb(data.models[modelName]);
});
}
});