diff --git a/lib/model-helper.js b/lib/model-helper.js index 51fbf53..598f0f1 100644 --- a/lib/model-helper.js +++ b/lib/model-helper.js @@ -121,9 +121,12 @@ var modelHelper = module.exports = { if (typeof propType === 'function') { // See https://github.com/strongloop/loopback-explorer/issues/32 // The type can be a model class - propType = propType.modelName || propType.name.toLowerCase(); - } else if(Array.isArray(propType)) { - propType = 'array'; + return propType.modelName || propType.name.toLowerCase(); + } else if (Array.isArray(propType)) { + return 'array'; + } else if (typeof propType === 'object') { + // Anonymous objects, they are allowed e.g. in accepts/returns definitions + return 'object'; } return propType; }, diff --git a/lib/route-helper.js b/lib/route-helper.js index d148cea..98ea610 100644 --- a/lib/route-helper.js +++ b/lib/route-helper.js @@ -123,21 +123,40 @@ var routeHelper = module.exports = { debug('route %j', route); + var responseDoc = modelHelper.LDLPropToSwaggerDataType(returns); + + // Note: Swagger Spec does not provide a way how to specify + // that the responseModel is "array of X". However, + // Swagger UI converts Arrays to the item types anyways, + // therefore it should be ok to do the same here. + var responseModel = responseDoc.type === 'array' ? + responseDoc.items.type : responseDoc.type; + + var responseMessages = [{ + code: route.returns && route.returns.length ? 200 : 204, + message: 'Request was successful', + responseModel: responseModel + }]; + + if (route.errors) { + responseMessages.push.apply(responseMessages, route.errors); + } + var apiDoc = { path: routeHelper.convertPathFragments(route.path), - // Create the operation doc. Use `extendWithType` to add the necessary - // `items` and `format` fields. - operations: [routeHelper.extendWithType({ + // Create the operation doc. + // Note that we are not calling `extendWithType`, as the response type + // is specified in the first response message. + operations: [{ method: routeHelper.convertVerb(route.verb), // [rfeng] Swagger UI doesn't escape '.' for jQuery selector - nickname: route.method.replace(/\./g, '_'), + nickname: route.method.replace(/\./g, '_'), parameters: accepts, - // TODO(schoon) - We don't have descriptions for this yet. - responseMessages: [], + responseMessages: responseMessages, summary: typeConverter.convertText(route.description), notes: typeConverter.convertText(route.notes), deprecated: route.deprecated - }, returns)] + }] }; return apiDoc; diff --git a/lib/swagger.js b/lib/swagger.js index 0edd55f..32843b7 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -12,6 +12,7 @@ var urlJoin = require('./url-join'); var _defaults = require('lodash.defaults'); var classHelper = require('./class-helper'); var routeHelper = require('./route-helper'); +var modelHelper = require('./model-helper'); var cors = require('cors'); /** @@ -23,6 +24,9 @@ var cors = require('cors'); * @param {Object} opts Options. */ function Swagger(loopbackApplication, swaggerApp, opts) { + if (opts && opts.swaggerVersion) + console.warn('loopback-explorer\'s options.swaggerVersion is deprecated.'); + opts = _defaults(opts || {}, { swaggerVersion: '1.2', basePath: loopbackApplication.get('restApiRoot') || '/api', @@ -81,6 +85,47 @@ function Swagger(loopbackApplication, swaggerApp, opts) { routeHelper.addRouteToAPIDeclaration(route, classDef, doc); }); + // Add models referenced from routes (e.g. accepts/returns) + Object.keys(apiDocs).forEach(function(className) { + var classDoc = apiDocs[className]; + classDoc.apis.forEach(function(api) { + api.operations.forEach(function(routeDoc) { + routeDoc.parameters.forEach(function(param) { + var type = param.type; + if (type === 'array' && param.items) + type = param.items.type; + + addTypeToModels(type); + }); + + addTypeToModels(routeDoc.type); + + routeDoc.responseMessages.forEach(function(msg) { + addTypeToModels(msg.responseModel); + }); + + function addTypeToModels(name) { + if (!name || name === 'void') return; + + var model = loopbackApplication.models[name]; + if (!model) { + var loopback = loopbackApplication.loopback; + if (!loopback) return; + + if (loopback.findModel) { + model = loopback.findModel(name); // LoopBack 2.x + } else { + model = loopback.getModel(name); // LoopBack 1.x + } + } + if (!model) return; + + modelHelper.generateModelDefinition(model, classDoc.models); + } + }); + }); + }); + /** * The topmost Swagger resource is a description of all (non-Swagger) * resources available on the system, and where to find more @@ -114,12 +159,14 @@ function addRoute(app, uri, doc, opts) { // 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. + // The simplest way around this is to reflect the value of the `Host` and/or + // `X-Forwarded-Host` HTTP headers as the `basePath`. + // Because we pre-build the Swagger data, we don't know that header at + // the time the data is built. if (hasBasePath) { var headers = req.headers; - var host = headers.Host || headers.host; + // NOTE header names (keys) are always all-lowercase + var host = headers['x-forwarded-host'] || headers.host; doc.basePath = (opts.protocol || req.protocol) + '://' + host + initialPath; } diff --git a/public/css/loopbackStyles.css b/public/css/loopbackStyles.css index 04d6894..5a56782 100644 --- a/public/css/loopbackStyles.css +++ b/public/css/loopbackStyles.css @@ -34,7 +34,12 @@ color: #080; } -/* -FIXME: Separate the overrides from the rest of the styles, rather than override screen.css entirely. -*/ +/* Improve spacing when the browser window is small */ +#message-bar, #swagger-ui-container { + padding-left: 30px; + padding-right: 30px; +} +#api_selector { + padding: 0px 20px; +} diff --git a/test/model-helper.test.js b/test/model-helper.test.js index b183400..1fb6414 100644 --- a/test/model-helper.test.js +++ b/test/model-helper.test.js @@ -214,6 +214,13 @@ describe('model-helper', function() { expect(def.properties).to.have.property('visibleProperty'); }); }); + + describe('getPropType', function() { + it('converts anonymous object types', function() { + var type = modelHelper.getPropType({ name: 'string', value: 'string' }); + expect(type).to.eql('object'); + }); + }); }); // Simulates the format of a remoting class. diff --git a/test/route-helper.test.js b/test/route-helper.test.js index e5a85e8..cfe2010 100644 --- a/test/route-helper.test.js +++ b/test/route-helper.test.js @@ -13,7 +13,8 @@ describe('route-helper', function() { { arg: 'avg', type: 'number' } ] }); - expect(doc.operations[0].type).to.equal('object'); + expect(doc.operations[0].type).to.equal(undefined); + expect(getResponseType(doc.operations[0])).to.equal('object'); }); it('converts path params when they exist in the route name', function() { @@ -60,19 +61,12 @@ describe('route-helper', function() { ] }); var opDoc = doc.operations[0]; - expect(opDoc.type).to.equal('array'); - expect(opDoc.items).to.eql({type: 'customType'}); - }); + // Note: swagger-ui treat arrays of X the same way as object X + expect(getResponseType(opDoc)).to.equal('customType'); - it('correctly converts return types (format)', function() { - var doc = createAPIDoc({ - returns: [ - {arg: 'data', type: 'buffer'} - ] - }); - var opDoc = doc.operations[0]; - expect(opDoc.type).to.equal('string'); - expect(opDoc.format).to.equal('byte'); + // NOTE(bajtos) this would be the case if there was a single response type + // expect(opDoc.type).to.equal('array'); + // expect(opDoc.items).to.eql({type: 'customType'}); }); it('includes `notes` metadata', function() { @@ -151,12 +145,45 @@ describe('route-helper', function() { .to.have.property('enum').eql([1,2,3]); }); - it('preserves `enum` returns arg metadata', function() { + it('includes the default response message with code 200', function() { var doc = createAPIDoc({ - returns: [{ name: 'arg', root: true, type: 'number', enum: [1,2,3] }] + returns: [{ name: 'result', type: 'object', root: true }] + }); + expect(doc.operations[0].responseMessages).to.eql([ + { + code: 200, + message: 'Request was successful', + responseModel: 'object' + } + ]); + }); + + it('uses the response code 204 when `returns` is empty', function() { + var doc = createAPIDoc({ + returns: [] + }); + expect(doc.operations[0].responseMessages).to.eql([ + { + code: 204, + message: 'Request was successful', + responseModel: 'void' + } + ]); + }); + + it('includes custom error response in `responseMessages`', function() { + var doc = createAPIDoc({ + errors: [{ + code: 422, + message: 'Validation failed', + responseModel: 'ValidationError' + }] + }); + expect(doc.operations[0].responseMessages[1]).to.eql({ + code: 422, + message: 'Validation failed', + responseModel: 'ValidationError' }); - expect(doc.operations[0]) - .to.have.property('enum').eql([1,2,3]); }); }); @@ -168,3 +195,7 @@ function createAPIDoc(def) { method: 'test.get' })); } + +function getResponseType(operationDoc) { + return operationDoc.responseMessages[0].responseModel; +} diff --git a/test/swagger.test.js b/test/swagger.test.js index c6f0987..9a79082 100644 --- a/test/swagger.test.js +++ b/test/swagger.test.js @@ -13,7 +13,7 @@ describe('swagger definition', function() { describe('basePath', function() { // No basepath on resource doc in 1.2 it('no longer exists on resource doc', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); var getReq = getSwaggerResources(app); getReq.end(function(err, res) { @@ -24,7 +24,7 @@ describe('swagger definition', function() { }); it('is "http://{host}/api" by default', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -35,7 +35,7 @@ describe('swagger definition', function() { }); it('is "http://{host}/{basePath}" when basePath is a path', function(done){ - var app = mountSwagger({ basePath: '/api-root'}); + var app = givenAppWithSwagger({ basePath: '/api-root'}); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -47,7 +47,7 @@ describe('swagger definition', function() { }); it('infers API basePath from app', function(done){ - var app = mountSwagger({}, {apiRoot: '/custom-api-root'}); + var app = givenAppWithSwagger({}, {apiRoot: '/custom-api-root'}); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -60,7 +60,7 @@ describe('swagger definition', function() { it('is reachable when explorer mounting location is changed', function(done){ var explorerRoot = '/erforscher'; - var app = mountSwagger({}, {explorerRoot: explorerRoot}); + var app = givenAppWithSwagger({}, {explorerRoot: explorerRoot}); var getReq = getSwaggerResources(app, explorerRoot, 'products'); getReq.end(function(err, res) { @@ -71,7 +71,7 @@ describe('swagger definition', function() { }); it('respects a hardcoded protocol (behind SSL terminator)', function(done){ - var app = mountSwagger({protocol: 'https'}); + var app = givenAppWithSwagger({protocol: 'https'}); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -81,11 +81,23 @@ describe('swagger definition', function() { done(); }); }); + + it('respects X-Forwarded-Host header (behind a proxy)', function(done) { + var app = givenAppWithSwagger(); + getAPIDeclaration(app, 'products') + .set('X-Forwarded-Host', 'example.com') + .end(function(err, res) { + if (err) return done(err); + var baseUrl = url.parse(res.body.basePath); + expect(baseUrl.hostname).to.equal('example.com'); + done(); + }); + }); }); describe('Model definition attributes', function() { it('Properly defines basic attributes', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); var getReq = getAPIDeclaration(app, 'products'); getReq.end(function(err, res) { @@ -106,7 +118,7 @@ describe('swagger definition', function() { }); it('includes `consumes`', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); getAPIDeclaration(app, 'products').end(function(err, res) { if (err) return done(err); expect(res.body.consumes).to.have.members([ @@ -119,7 +131,7 @@ describe('swagger definition', function() { }); it('includes `produces`', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); getAPIDeclaration(app, 'products').end(function(err, res) { if (err) return done(err); expect(res.body.produces).to.have.members([ @@ -131,11 +143,167 @@ describe('swagger definition', function() { done(); }); }); + + it('includes models from `accepts` args', function(done) { + var app = createLoopbackAppWithModel(); + givenPrivateAppModel(app, 'Image'); + givenSharedMethod(app.models.Product, 'setImage', { + accepts: { name: 'image', type: 'Image' } + }); + mountExplorer(app); + + getAPIDeclaration(app, 'products').end(function(err, res) { + expect(Object.keys(res.body.models)).to.include('Image'); + done(); + }); + }); + + it('includes models from `returns` args', function(done) { + var app = createLoopbackAppWithModel(); + givenPrivateAppModel(app, 'Image'); + givenSharedMethod(app.models.Product, 'getImage', { + returns: { name: 'image', type: 'Image' } + }); + mountExplorer(app); + + getAPIDeclaration(app, 'products').end(function(err, res) { + expect(Object.keys(res.body.models)).to.include('Image'); + done(); + }); + }); + + it('includes `accepts` models not attached to the app', function(done) { + var app = createLoopbackAppWithModel(); + loopback.createModel('Image'); + givenSharedMethod(app.models.Product, 'setImage', { + accepts: { name: 'image', type: 'Image' } + }); + mountExplorer(app); + + getAPIDeclaration(app, 'products').end(function(err, res) { + expect(Object.keys(res.body.models)).to.include('Image'); + done(); + }); + }); + + it('includes `responseMessages` models', function(done) { + var app = createLoopbackAppWithModel(); + loopback.createModel('ValidationError'); + givenSharedMethod(app.models.Product, 'setImage', { + errors: [{ + code: '422', + message: 'Validation failed', + responseModel: 'ValidationError' + }] + }); + + expectProductDocIncludesModels(app, 'ValidationError', done); + }); + + it('includes nested model references in properties', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + app.models.Product.defineProperty('location', { type: 'Warehouse' }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested array model references in properties', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + app.models.Product.defineProperty('location', { type: ['Warehouse'] }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested model references in modelTo relation', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + app.models.Product.belongsTo(app.models.Warehouse); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested model references in modelTo relation', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + givenPrivateAppModel(app, 'ProductLocations'); + + app.models.Product.hasMany(app.models.Warehouse, + { through: app.models.ProductLocations }); + + expectProductDocIncludesModels( + app, + ['Address', 'Warehouse', 'ProductLocations'], + done); + }); + + it('includes nested model references in accept args', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + accepts: { arg: 'w', type: 'Warehouse' } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested array model references in accept args', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + accepts: { arg: 'w', type: [ 'Warehouse' ] } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested model references in return args', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + returns: { arg: 'w', type: 'Warehouse', root: true } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested array model references in return args', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + returns: { arg: 'w', type: ['Warehouse'], root: true } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); + + it('includes nested model references in error responses', function(done) { + var app = createLoopbackAppWithModel(); + givenWarehouseWithAddressModels(app); + + givenSharedMethod(app.models.Product, 'aMethod', { + errors: { + code: '222', + message: 'Warehouse', + responseModel: 'Warehouse' + } + }); + + expectProductDocIncludesModels(app, ['Address', 'Warehouse'], done); + }); }); describe('Cross-origin resource sharing', function() { it('allows cross-origin requests by default', function(done) { - var app = mountSwagger(); + var app = givenAppWithSwagger(); request(app) .options('/explorer/resources') .set('Origin', 'http://example.com/') @@ -145,7 +313,7 @@ describe('swagger definition', function() { }); it('can be disabled by configuration', function(done) { - var app = mountSwagger({}, { remoting: { cors: { origin: false } } }); + var app = givenAppWithSwagger({}, { remoting: { cors: { origin: false } } }); request(app) .options('/explorer/resources') .end(function(err, res) { @@ -162,34 +330,43 @@ describe('swagger definition', function() { return request(app) .get(urlJoin(restPath || '/explorer', '/resources', classPath || '')) .set('Accept', 'application/json') - .expect('Content-Type', /json/) - .expect(200); + .expect(200) + .expect('Content-Type', /json/); } function getAPIDeclaration(app, className) { return getSwaggerResources(app, '', urlJoin('/', className)); } - function mountSwagger(options, addlOptions) { - addlOptions = addlOptions || {}; - var app = createLoopbackAppWithModel(addlOptions.apiRoot); + function givenAppWithSwagger(swaggerOptions, appConfig) { + appConfig = appConfig || {}; + var app = createLoopbackAppWithModel(appConfig.apiRoot); + + if (appConfig.remoting) app.set('remoting', appConfig.remoting); + if (appConfig.explorerRoot) app.set('explorerRoot', appConfig.explorerRoot); + + mountExplorer(app, swaggerOptions); + return app; + } + + function mountExplorer(app, options) { var swaggerApp = express(); - if (addlOptions.remoting) app.set('remoting', addlOptions.remoting); swagger(app, swaggerApp, options); - app.use(addlOptions.explorerRoot || '/explorer', swaggerApp); + app.use(app.get('explorerRoot') || '/explorer', swaggerApp); return app; } function createLoopbackAppWithModel(apiRoot) { var app = loopback(); + app.dataSource('db', { connector: 'memory' }); + 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.model(Product, { dataSource: 'db'}); // Simulate a restApiRoot set in config app.set('restApiRoot', apiRoot || '/api'); @@ -197,4 +374,33 @@ describe('swagger definition', function() { return app; } + + function givenSharedMethod(model, name, metadata) { + model[name] = function(){}; + loopback.remoteMethod(model[name], metadata); + } + + function givenPrivateAppModel(app, name, properties) { + var model = loopback.createModel(name, properties); + app.model(model, { dataSource: 'db', public: false} ); + } + + function givenWarehouseWithAddressModels(app) { + givenPrivateAppModel(app, 'Address'); + givenPrivateAppModel(app, 'Warehouse', { + shippingAddress: { type: 'Address' } + }); + } + + function expectProductDocIncludesModels(app, modelNames, done) { + if (!Array.isArray(modelNames)) modelNames = [modelNames]; + + mountExplorer(app); + + getAPIDeclaration(app, 'products').end(function(err, res) { + if (err) return done(err); + expect(Object.keys(res.body.models)).to.include.members(modelNames); + done(); + }); + } });