Skip to content

Commit

Permalink
Add option to specify routes by hand (#60)
Browse files Browse the repository at this point in the history
Format is { path: '/foo/{id}', module: require('./foo') }
  • Loading branch information
mika-fischer authored and jsdevel committed Oct 18, 2016
1 parent 0e35223 commit c40a51f
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 25 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ openapi.initialize({

|Type|Required|Description|
|----|--------|-----------|
|String or Array|Y|Relative path or paths to the directory or directories that contain your route files.|
|String or Array|Y|Relative path or paths to the directory or directories that contain your route files or route specifications.|


Path files are logically structured according to their URL path. For cross platform
Expand Down Expand Up @@ -671,6 +671,9 @@ post.apiDoc = {

```
Alternatively, args.paths may contain route specifications of the form
`{ path: '/foo/{id}', module: require('./handlers/foo') }`.
Modules under `args.paths` expose methods. Methods may either be a method handler
function, or an array of business specific middleware + a method handler function.
Expand Down
7 changes: 6 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,18 @@ export declare module OpenApi {
attribute?: boolean
wrapped?: boolean
}

export interface RouteSpecification {
path: string
module: PathModule
}
}

export interface Args {
apiDoc: OpenApi.ApiDefinition
app: express.Application
routes?: string | string[]
paths: string | string[]
paths: string | string[] | OpenApi.RouteSpecification[]
docsPath?: string
errorMiddleware?: express.ErrorRequestHandler,
errorTransformer?(openapiError: OpenapiError, jsonschemaError: JsonschemaError): any
Expand Down
64 changes: 42 additions & 22 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ function initialize(args) {
}
}

var paths = [].concat(args.paths);
var exposeApiDocs = 'exposeApiDocs' in args ?
!!args.exposeApiDocs :
true;
Expand All @@ -79,15 +78,10 @@ function initialize(args) {
}
}

if (!paths.filter(byString).length) {
throw new Error(loggingKey + 'args.paths must be a string or an array of strings');
}

paths = paths.map(toAbsolutePath);

if (!paths.filter(byDirectory).length) {
throw new Error(loggingKey + 'args.paths contained a value that was not a path to a directory');
if (!args.paths) {
throw new Error(loggingKey + 'args.paths is required')
}
var paths = [].concat(args.paths);

if (args.docsPath && typeof args.docsPath !== 'string') {
throw new Error(loggingKey + 'args.docsPath must be a string when given');
Expand Down Expand Up @@ -135,14 +129,40 @@ function initialize(args) {

pathSecurity.forEach(assertRegExpAndSecurity);

var loadPathModule = args.dependencies ? function (path) {
return dependencyInjection(args.dependencies, require(path));
} : function (path) {
return require(path);
var injectDependencies = args.dependencies ? function (handlers) {
return dependencyInjection(args.dependencies, handlers);
} : function (handlers) {
return handlers;
};
[].concat.apply([], paths.map(fsRoutes)).sort(byRoute).forEach(function(result) {
var pathModule = loadPathModule(result.path);
var route = result.route;

var routes = [];
paths.forEach(function(pathItem) {
if (byString(pathItem)) {
pathItem = toAbsolutePath(pathItem);
if (!byDirectory(pathItem)) {
throw new Error(loggingKey + 'args.paths contained a value that was not a path to a directory');
}
routes = routes.concat(fsRoutes(pathItem).map(function(fsRoutesItem) {
return { path: fsRoutesItem.route, module: require(fsRoutesItem.path) };
}));
} else {
if (!pathItem.path || !pathItem.module ) {
throw new Error(loggingKey + 'args.paths must consist of strings or valid route specifications');
}
routes.push(pathItem);
}
});
routes = routes.sort(byRoute);

// Check for duplicate routes
var dups = routes.filter(function(v,i,o){if(i>0 && v.path === o[i-1].path) return v.path;});
if (dups.length > 0) {
throw new Error(loggingKey + 'args.paths produced duplicate urls: ' + dups);
}

routes.forEach(function(routeItem) {
var route = routeItem.path;
var pathModule = injectDependencies(routeItem.module);
// express path params start with :paramName
// openapi path params use {paramName}
var openapiPath = route;
Expand Down Expand Up @@ -187,7 +207,7 @@ function initialize(args) {
definitions: apiDoc.definitions,
externalSchemas: externalSchemas,
errorTransformer: errorTransformer,
responses: resolveResponseRefs(operationDoc.responses, apiDoc, result.path),
responses: resolveResponseRefs(operationDoc.responses, apiDoc, route),
customFormats: customFormats
}));
}
Expand Down Expand Up @@ -377,9 +397,9 @@ function byProperty(property, value) {
}

function byRoute(a, b) {
if(isDynamicRoute(a.route) && !isDynamicRoute(b.route)) return 1;
if(!isDynamicRoute(a.route) && isDynamicRoute(b.route)) return -1;
return a.route.localeCompare(b.route);
if(isDynamicRoute(a.path) && !isDynamicRoute(b.path)) return 1;
if(!isDynamicRoute(a.path) && isDynamicRoute(b.path)) return -1;
return a.path.localeCompare(b.path);
}

function isDynamicRoute(route) {
Expand Down Expand Up @@ -483,7 +503,7 @@ function resolveParameterRefs(parameters, definitions) {
});
}

function resolveResponseRefs(responses, apiDoc, pathToModule) {
function resolveResponseRefs(responses, apiDoc, route) {
return Object.keys(responses).reduce(function(resolvedResponses, responseCode) {
var response = responses[responseCode];

Expand All @@ -499,7 +519,7 @@ function resolveResponseRefs(responses, apiDoc, pathToModule) {

if (match[1] === 'definitions') {
console.warn('Using "$ref: \'#/definitions/...\'" for responses has been deprecated.');
console.warn('Please switch to "$ref: \'#/responses/...\'" in ' + pathToModule + '.');
console.warn('Please switch to "$ref: \'#/responses/...\'" in handler(s) for ' + route + '.');
console.warn('Future versions of express-openapi will no longer support this.');
}

Expand Down
4 changes: 3 additions & 1 deletion test/initialization.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ describe(require('../package.json').name, function() {
['args.app must be an express app', {}, /express-openapi: args.app must be an express app/],
['args.apiDoc required', {app: {}}, /express-openapi: args.apiDoc is required/],
['args.apiDoc not valid', {app: {}, apiDoc: {}}, /express-openapi: args.apiDoc was invalid. See the output./],
['args.paths required', {app: {}, apiDoc: validDocument}, /express-openapi: args.paths must be a string/],
['args.paths required', {app: {}, apiDoc: validDocument}, /express-openapi: args.paths is required/],
['args.paths non directory', {app: {}, apiDoc: validDocument, paths: 'asdasdfasdf'}, /express-openapi: args.paths contained a value that was not a path to a directory/],
['args.paths non directory', {app: {}, apiDoc: validDocument, paths: routesDir, docsPath: true}, /express-openapi: args.docsPath must be a string when given/],
['args.paths with invalid route', {app: {}, apiDoc: validDocument, paths: [{foo: '/foo', bar: {}}]}, /express-openapi: args.paths must consist of strings or valid route specifications/],
['args.paths with duplicates', {app: {}, apiDoc: validDocument, paths: [{path: '/foo', module: {}}, {path: '/foo', module:{}}]}, /express-openapi: args.paths produced duplicate urls/],
['args.errorTransformer', {app: {}, apiDoc: validDocument, paths: routesDir, errorTransformer: 'asdf'}, /express-openapi: args.errorTransformer must be a function when given/],
['args.externalSchemas', {app: {}, apiDoc: validDocument, paths: routesDir, externalSchemas: 'asdf'}, /express-openapi: args.externalSchemas must be a object when given/],
['args.securityHandlers', {app: {}, apiDoc: validDocument, paths: routesDir, securityHandlers: 'asdf'}, /express-openapi: args.securityHandlers must be an object when given/],
Expand Down
44 changes: 44 additions & 0 deletions test/sample-projects/with-route-specs-using-modules/api-doc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// args.apiDoc needs to be a js object. This file could be a json file, but we can't add
// comments in json files.
module.exports = {
swagger: '2.0',

// all routes will now have /v3 prefixed.
basePath: '/v3',

info: {
title: 'express-openapi sample project',
version: '3.0.0'
},

definitions: {
Error: {
additionalProperties: true
},
User: {
properties: {
name: {
type: 'string'
},
friends: {
type: 'array',
items: {
$ref: '#/definitions/User'
}
}
},
required: ['name']
}
},

// paths are derived from args.routes. These are filled in by fs-routes.
paths: {},

// tags is optional, and is generated / sorted by the tags defined in your path
// docs. This API also defines 2 tags in operations: "creating" and "fooey".
tags: [
// {name: 'creating'} will be inserted by ./api-routes/users.js
// {name: 'fooey'} will be inserted by ./api-routes/users/{id}.js
{description: 'Everything users', name: 'users'}
]
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module.exports = {
get: get
};

function get(req, res, next) {
if (req.query.type === 'apiDoc') {
return res.json(req.apiDoc);
}
return res.json(req.operationDoc);
}
get.apiDoc = {
operationId: 'getApiDoc',
description: 'Returns the requested apiDoc',
parameters: [
{
description: 'The type of apiDoc to return.',
in: 'query',
name: 'type',
type: 'string',
enum: [
'apiDoc',
'operationDoc'
]
}
],
responses: {
200: {
description: 'The requested apiDoc.',
schema: {
type: 'object'
}
},
default: {
description: 'The requested apiDoc.',
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Showing that you don't need to have apiDoc defined on methodHandlers.
module.exports = {
del: function(req, res, next) {
// Showing how to validate responses
var validationError = res.validateResponse(204, null);

if (validationError) {
return next(validationError);
}

res.status(204).send('').end();
},
get: [function(req, res, next) {
res.status(200).json([{name: 'fred'}]);
}],

post: function(req, res, next) {
res.status(500).json({});
}
};

module.exports.del.apiDoc = {
description: 'Delete users.',
operationId: 'deleteUsers',
tags: ['users'],
parameters: [],
responses: {
204: {
description: 'Users were successfully deleted.'
// 204 should not return a body so not defining a schema. This adds an implicit
// schema of {"type": "null"}.
}
}
};

// showing that if parameters are empty, express-openapi adds no input middleware.
// response middleware is always added.
module.exports.post.apiDoc = {
description: 'Create a new user.',
operationId: 'createUser',
tags: ['users', 'creating'],
parameters: [],
responses: {
default: {
$ref: '#/definitions/Error'
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
module.exports = {
// parameters for all operations in this path
parameters: [
{
name: 'id',
in: 'path',
type: 'string',
required: true,
description: 'Fred\'s age.'
}
],
// method handlers may just be the method handler...
get: get,
// or they may also be an array of middleware + the method handler. This allows
// for flexible middleware management. express-openapi middleware generated from
// the <path>.parameters + <methodHandler>.apiDoc.parameters is prepended to this
// array.
post: [function(req, res, next) {next();}, post]
};

function post(req, res) {
res.status(200).json({id: req.params.id});
}

// verify that apiDoc is available with middleware
post.apiDoc = {
description: 'Create a user.',
operationId: 'createUser',
tags: ['users'],
parameters: [
{
name: 'user',
in: 'body',
schema: {
$ref: '#/definitions/User'
}
}
],

responses: {
default: {
$ref: '#/definitions/Error'
}
}
};

function get(req, res) {
res.status(200).json({
id: req.params.id,
name: req.query.name,
age: req.query.age
});
}

get.apiDoc = {
description: 'Retrieve a user.',
operationId: 'getUser',
tags: ['users', 'fooey'],
parameters: [
{
name: 'name',
in: 'query',
type: 'string',
pattern: '^fred$',
description: 'The name of this person. It may only be "fred".'
},
// showing that operation parameters override path parameters
{
name: 'id',
in: 'path',
type: 'integer',
required: true,
description: 'Fred\'s age.'
},
{
name: 'age',
in: 'query',
type: 'integer',
description: 'Fred\'s age.',
default: 80
}
],

responses: {
200: {
$ref: '#/definitions/User'
},

default: {
$ref: '#/definitions/Error'
}
}
};
Loading

0 comments on commit c40a51f

Please sign in to comment.