Use express routes instead of modifying remoting.

This completes the migration of swagger processing from strong-remoting
into loopback-explorer.

Added additional usage instructions to README and additional testing.

This commit introduces a change into where resource descriptors
are hosted. They are no longer hosted under /swagger, but instead
under the same path as the Explorer, wherever that may be.
Generally, the resource listing will be available at
/explorer/resources, and api listings under
/explorer/resources/{modelName}.
This commit is contained in:
Samuel Reed 2014-07-09 17:38:05 -05:00
parent 19c3fe3870
commit 70dddef296
8 changed files with 188 additions and 159 deletions

View File

@ -16,28 +16,82 @@ var Product = loopback.Model.extend('product');
Product.attachTo(loopback.memory());
app.model(Product);
app.use('/explorer', explorer(app, {} /* options */));
app.use(loopback.rest());
app.use('/explorer', explorer(app, {basePath: '/api'}));
app.use('/api', loopback.rest());
console.log("Explorer mounted at localhost:" + port + "/explorer");
app.listen(port);
```
## Advanced Usage
Many aspects of the explorer are configurable.
See [options](#options) for a description of these options:
```js
app.use('/explorer', explorer(app, {
basePath: '/custom-api-root',
preMiddleware: [
// You can add as many items to this middleware chain as you like
loopback.basicAuth(bitmex.settings.basicAuth.user, bitmex.settings.basicAuth.password)
],
swaggerDistRoot: '/swagger',
apiInfo: {
'title': 'My API',
'description': 'Explorer example app.'
},
resourcePath: 'swaggerResources',
version: '0.1-unreleasable'
}));
app.use('/custom-api-root', loopback.rest());
```
## Options
Options are passed to `explorer(app, options)`.
`basePath`: **String**
> Set the base path for swagger resources.
> Default: `app.get('restAPIRoot')` or `/swagger/resources`.
> Default: `app.get('restAPIRoot')` or `'/api'`.
> Sets the API's base path. This must be set if you are mounting your api
> to a path different than '/api', e.g. with
> `loopback.use('/custom-api-root', loopback.rest());
`swaggerDistRoot`: **String**
> Set a path within your application for overriding Swagger UI files.
> Sets a path within your application for overriding Swagger UI files.
> If present, will search `swaggerDistRoot` first when attempting to load Swagger UI, allowing
you to pick and choose overrides.
> you to pick and choose overrides to the interface. Use this to style your explorer or
> add additional functionality.
> See [index.html](public/index.html), where you may want to begin your overrides.
> The rest of the UI is provided by [Swagger UI](https://github.com/wordnik/swagger-ui).
`apiInfo`: **Object**
> Additional information about your API. See the
> [spec](https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#513-info-object).
`resourcePath`: **String**
> Default: `'resources'`
> Sets a different path for the
> [resource listing](https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#51-resource-listing).
> You generally shouldn't have to change this.
`version`: **String**
> Default: Read from package.json
> Sets your API version. If not present, will read from your app's package.json.
`preMiddleware`: **Array<Function>|Function**
> Middleware to run before any explorer routes, including static routes.
> Useful for setting HTTP Auth, modifying the `Host` header, and so on.

View File

@ -11,8 +11,9 @@ var Product = loopback.Model.extend('product', {
Product.attachTo(loopback.memory());
app.model(Product);
app.use('/explorer', explorer(app));
app.use(loopback.rest());
console.log("Explorer mounted at localhost:" + port + "/explorer");
var apiPath = '/api';
app.use('/explorer', explorer(app, {basePath: apiPath}));
app.use(apiPath, loopback.rest());
console.log('Explorer mounted at localhost:' + port + '/explorer');
app.listen(port);

View File

@ -9,7 +9,8 @@ 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 SWAGGER_UI_ROOT = path.join(__dirname, 'node_modules',
'swagger-ui', 'dist');
var STATIC_ROOT = path.join(__dirname, 'public');
module.exports = explorer;
@ -23,27 +24,38 @@ module.exports = explorer;
function explorer(loopbackApplication, options) {
options = _defaults({}, options, {
basePath: loopbackApplication.get('restApiRoot') || '',
name: 'swagger',
resourcePath: 'resources',
apiInfo: loopbackApplication.get('apiInfo') || {}
apiInfo: loopbackApplication.get('apiInfo') || {},
preMiddleware: []
});
swagger(loopbackApplication.remotes(), options);
var app = express();
swagger(loopbackApplication, app, options);
// Allow the user to attach middleware that will run before any
// explorer routes, e.g. for access control.
if (typeof options.preMiddleware === 'function' ||
(Array.isArray(options.preMiddleware) && options.preMiddleware.length)) {
app.use(options.preMiddleware);
}
app.disable('x-powered-by');
// config.json is loaded by swagger-ui. The server should respond
// with the relative URI of the resource doc.
app.get('/config.json', function(req, res) {
var resourcePath = req.originalUrl.replace(/\/config.json(\?.*)?$/,
path.join('/', options.resourcePath));
res.send({
url: path.join(options.basePath || '/', options.name, options.resourcePath)
url: resourcePath
});
});
// Allow specifying a static file root for swagger files. Any files in that folder
// will override those in the swagger-ui distribution. In this way one could e.g.
// make changes to index.html without having to worry about constantly pulling in
// JS updates.
// Allow specifying a static file root for swagger files. Any files in
// that folder will override those in the swagger-ui distribution.
// In this way one could e.g. make changes to index.html without having
// to worry about constantly pulling in JS updates.
if (options.swaggerDistRoot) {
app.use(loopback.static(options.swaggerDistRoot));
}
@ -51,6 +63,7 @@ function explorer(loopbackApplication, options) {
app.use(loopback.static(STATIC_ROOT));
// Swagger UI distribution
app.use(loopback.static(SWAGGER_UI_ROOT));
return app;
}

View File

@ -10,8 +10,6 @@ var path = require('path');
* Export the classHelper singleton.
*/
var classHelper = module.exports = {
// See below.
addDynamicBasePathGetter: addDynamicBasePathGetter,
/**
* Given a remoting class, generate an API doc.
* See https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md#52-api-declaration
@ -47,52 +45,3 @@ var classHelper = module.exports = {
};
}
};
/**
* 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
});
}
}

View File

@ -7,7 +7,6 @@ module.exports = Swagger;
/**
* Module dependencies.
*/
var Remoting = require('strong-remoting');
var debug = require('debug')('loopback-explorer:swagger');
var path = require('path');
var _defaults = require('lodash.defaults');
@ -17,38 +16,38 @@ var routeHelper = require('./route-helper');
/**
* Create a remotable Swagger module for plugging into `RemoteObjects`.
*
* @param {Application} loopbackApplication Host loopback application.
* @param {Application} swaggerApp Swagger application used for hosting
* these files.
* @param {Object} opts Options.
*/
function Swagger(remotes, opts) {
function Swagger(loopbackApplication, swaggerApp, opts) {
opts = _defaults({}, opts, {
name: 'swagger',
swaggerVersion: '1.2',
basePath: loopbackApplication.get('restApiRoot') || '/api',
resourcePath: 'resources',
version: getVersion(),
basePath: '/'
version: getVersion()
});
// We need a temporary REST adapter to discover our available routes.
var remotes = loopbackApplication.remotes();
var adapter = remotes.handler('rest').adapter;
var routes = adapter.allRoutes();
var classes = remotes.classes();
// Create a new Remoting instance to host the swagger docs.
var extension = {};
var helper = Remoting.extend(extension);
// These are the docs we will be sending from the /swagger endpoints.
var resourceDoc = generateResourceDoc(opts);
var apiDocs = {};
// A class is an endpoint root; e.g. /users, /products, and so on.
classes.forEach(function (aClass) {
apiDocs[aClass.name] = classHelper.generateAPIDoc(aClass, opts);
var doc = apiDocs[aClass.name] = classHelper.generateAPIDoc(aClass, opts);
resourceDoc.apis.push(classHelper.generateResourceDocAPIEntry(aClass));
// Add the getter for this doc.
var docPath = path.join(opts.resourcePath, aClass.http.path);
addRoute(helper, apiDocs[aClass.name], docPath);
classHelper.addDynamicBasePathGetter(remotes, opts.name + '.' + docPath, apiDocs[aClass.name]);
addRoute(swaggerApp, docPath, doc);
});
// A route is an endpoint, such as /users/findOne.
@ -69,31 +68,41 @@ function Swagger(remotes, opts) {
});
/**
* The topmost Swagger resource is a description of all (non-Swagger) resources
* available on the system, and where to find more information about them.
* The topmost Swagger resource is a description of all (non-Swagger)
* resources available on the system, and where to find more
* information about them.
*/
addRoute(helper, resourceDoc, opts.resourcePath);
// Bind all the above routes to the endpoint at /#{name}.
remotes.exports[opts.name] = extension;
return extension;
addRoute(swaggerApp, opts.resourcePath, resourceDoc);
}
/**
* Add a route to this remoting extension.
* @param {Remote} helper Remoting extension.
* @param {Object} doc Doc to serve.
* @param {String} path Path from which to serve the doc.
* @param {Application} app Express application.
* @param {String} uri Path from which to serve the doc.
* @param {Object} doc Doc to serve.
*/
function addRoute(helper, doc, path) {
helper.method(getDoc, {
path: path,
returns: { type: 'object', root: true }
function addRoute(app, uri, doc) {
var hasBasePath = Object.keys(doc).indexOf('basePath') !== -1;
var initialPath = doc.basePath || '';
app.get(path.join('/', uri), function(req, res) {
// There's a few forces at play that require this "hack". The Swagger spec
// requires a `basePath` to be set in the API 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.
if (hasBasePath) {
var headers = req.headers;
var host = headers.Host || headers.host;
doc.basePath = req.protocol + '://' + host + initialPath;
}
res.send(200, doc);
});
function getDoc(callback) {
callback(null, doc);
}
}
/**

View File

@ -29,7 +29,7 @@
"mocha": "~1.14.0",
"supertest": "~0.8.1",
"chai": "~1.8.1",
"strong-remoting": "^2.0.0-beta4"
"express": "3.x"
},
"license": {
"name": "Dual MIT/StrongLoop",

View File

@ -38,24 +38,24 @@ describe('explorer', function() {
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to
.have.property('url', '/swagger/resources');
.have.property('url', '/explorer/resources');
done();
});
});
});
describe('with custom baseUrl', function() {
beforeEach(givenLoopBackAppWithExplorer('/api'));
describe('with custom explorer base', function() {
beforeEach(givenLoopBackAppWithExplorer('/swagger'));
it('should serve correct swagger-ui config', function(done) {
request(this.app)
.get('/explorer/config.json')
.get('/swagger/config.json')
.expect('Content-Type', /json/)
.expect(200)
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to
.have.property('url', '/api/swagger/resources');
.have.property('url', '/swagger/resources');
done();
});
});
@ -73,36 +73,26 @@ describe('explorer', function() {
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to
.have.property('url', '/rest-api-root/swagger/resources');
.have.property('url', '/explorer/resources');
done();
});
});
});
function givenLoopBackAppWithExplorer(restUrlBase) {
function givenLoopBackAppWithExplorer(explorerBase) {
return function(done) {
var app = this.app = loopback();
configureRestApiAndExplorer(app, restUrlBase);
configureRestApiAndExplorer(app, explorerBase);
done();
};
}
function configureRestApiAndExplorer(app, restUrlBase) {
function configureRestApiAndExplorer(app, explorerBase) {
var Product = loopback.Model.extend('product');
Product.attachTo(loopback.memory());
app.model(Product);
if (restUrlBase) {
app.use(restUrlBase, loopback.rest());
app.use('/explorer', explorer(app, { basePath: restUrlBase }));
} else {
// LoopBack REST adapter owns the whole URL space and does not
// let other middleware handle same URLs.
// It's possible to circumvent this measure by installing
// the explorer middleware before the REST middleware.
// This way we can acess `/explorer` even when REST is mounted at `/`
app.use('/explorer', explorer(app));
app.use(app.get('restApiRoot') || '/', loopback.rest());
}
app.use(explorerBase || '/explorer', explorer(app));
app.use(app.get('restApiRoot') || '/', loopback.rest());
}
});

View File

@ -22,26 +22,19 @@
var url = require('url');
var path = require('path');
var loopback = require('loopback');
var RemoteObjects = require('strong-remoting');
var express = require('express');
var swagger = require('../lib/swagger.js');
var request = require('supertest');
var expect = require('chai').expect;
describe('swagger definition', function() {
var app;
beforeEach(function() {
app = createLoopbackAppWithModel();
});
describe('basePath', function() {
// No basepath on resource doc in 1.2
it('no longer exists on resource doc', function(done) {
swagger(app.remotes());
var app = mountSwagger();
var getReq = getSwaggerResources();
var getReq = getSwaggerResources(app);
getReq.end(function(err, res) {
if (err) return done(err);
expect(res.body.basePath).to.equal(undefined);
@ -49,21 +42,21 @@ describe('swagger definition', function() {
});
});
it('is "http://{host}/" by default', function(done) {
swagger(app.remotes());
it('is "http://{host}/api" by default', function(done) {
var app = mountSwagger();
var getReq = getAPIDeclaration('products');
var getReq = getAPIDeclaration(app, 'products');
getReq.end(function(err, res) {
if (err) return done(err);
expect(res.body.basePath).to.equal(url.resolve(getReq.url, '/'));
expect(res.body.basePath).to.equal(url.resolve(getReq.url, '/api'));
done();
});
});
it('is "http://{host}/{basePath}" when basePath is a path', function(done){
swagger(app.remotes(), { basePath: '/api-root'});
var app = mountSwagger({ basePath: '/api-root'});
var getReq = getAPIDeclaration('products');
var getReq = getAPIDeclaration(app, 'products');
getReq.end(function(err, res) {
if (err) return done(err);
var apiRoot = url.resolve(getReq.url, '/api-root');
@ -72,15 +65,26 @@ describe('swagger definition', function() {
});
});
it('is custom URL when basePath is a http(s) URL', function(done) {
var apiUrl = 'http://custom-api-url/';
it('infers API basePath from app', function(done){
var app = mountSwagger({}, {apiRoot: '/custom-api-root'});
swagger(app.remotes(), { basePath: apiUrl });
var getReq = getAPIDeclaration('products');
var getReq = getAPIDeclaration(app, 'products');
getReq.end(function(err, res) {
if (err) return done(err);
expect(res.body.basePath).to.equal(apiUrl);
var apiRoot = url.resolve(getReq.url, '/custom-api-root');
expect(res.body.basePath).to.equal(apiRoot);
done();
});
});
it('is reachable when explorer mounting location is changed', function(done){
var explorerRoot = '/erforscher';
var app = mountSwagger({}, {explorerRoot: explorerRoot});
var getReq = getSwaggerResources(app, explorerRoot, 'products');
getReq.end(function(err, res) {
if (err) return done(err);
expect(res.body.basePath).to.be.a('string');
done();
});
});
@ -88,8 +92,12 @@ describe('swagger definition', function() {
describe('Model definition attributes', function() {
it('Properly defines basic attributes', function(done) {
var extension = swagger(app.remotes(), {});
getModelFromRemoting(extension, 'product', function(data) {
var app = mountSwagger();
var getReq = getAPIDeclaration(app, 'products');
getReq.end(function(err, res) {
if (err) return done(err);
var data = res.body.models.product;
expect(data.id).to.equal('product');
expect(data.required.sort()).to.eql(['id', 'aNum', 'foo'].sort());
expect(data.properties.foo.type).to.equal('string');
@ -105,19 +113,28 @@ describe('swagger definition', function() {
});
});
function getSwaggerResources(restPath, classPath) {
function getSwaggerResources(app, restPath, classPath) {
return request(app)
.get(path.join(restPath || '', '/swagger/resources', classPath || ''))
.get(path.join(restPath || '/explorer', '/resources', classPath || ''))
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200);
}
function getAPIDeclaration(className) {
return getSwaggerResources('', path.join('/', className));
function getAPIDeclaration(app, className) {
return getSwaggerResources(app, '', path.join('/', className));
}
function createLoopbackAppWithModel() {
function mountSwagger(options, addlOptions) {
addlOptions = addlOptions || {};
var app = createLoopbackAppWithModel(addlOptions.apiRoot);
var swaggerApp = express();
swagger(app, swaggerApp, options);
app.use(addlOptions.explorerRoot || '/explorer', swaggerApp);
return app;
}
function createLoopbackAppWithModel(apiRoot) {
var app = loopback();
var Product = loopback.Model.extend('product', {
@ -128,14 +145,10 @@ describe('swagger definition', function() {
Product.attachTo(loopback.memory());
app.model(Product);
app.use(loopback.rest());
// Simulate a restApiRoot set in config
app.set('restApiRoot', apiRoot || '/api');
app.use(app.get('restApiRoot'), loopback.rest());
return app;
}
function getModelFromRemoting(extension, modelName, cb) {
extension['resources/' + modelName + 's'](function(err, data) {
cb(data.models[modelName]);
});
}
});