Skip to content

Commit

Permalink
Merge branch 'input-type-doc' into 'master'
Browse files Browse the repository at this point in the history
Input type doc

- Ignore mime-types accept for GET, HEAD and DELETE operation docs
  - We'll probably revert this eventually
  - Check this out: OAI/OpenAPI-Specification#1937
- Add accepted mime-types to operation doc
- Add request type filtering to readme
- Add default permissive request body description to doc generation
- Fix unknown type "as-is" on cli options
- Move accepts type parsing to own file
- Update test suite to listen based on a constant instead of explicit 'localhost'


See merge request GCSBOSS/nodecaf!20
  • Loading branch information
GCSBOSS committed Jul 4, 2019
2 parents b23b3af + aa617ee commit 9a44417
Show file tree
Hide file tree
Showing 8 changed files with 250 additions and 90 deletions.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Using Nodecaf you'll get:
- [HTTPS capability](#https).
- Functions to [describe your API](#api-description) making your code the main
source of truth.
- Functions to [filter request bodies](#filter-requests-by-mime-type) by mime-type.
- CLI command to [generate a basic Nodecaf project structure](#init-project).
- CLI command to [generate an OpenAPI document](#open-api-support) or your APIs.

Expand Down Expand Up @@ -377,6 +378,40 @@ cert = "/path/to/cert.pem"

When SSL is enabled the default server port becomes 443.

### Filter Requests by Mime-type

Nodecaf allow you to reject request bodies whose mime-type is not in a defined
white-list. Denied requests will receive a 400 response with the apporpriate
message.

Define a filter for the entire app on your `api.js`:

```
module.exports = function({ }){
this.accept(['json', 'text/html']);
}
```

Override the global accept per route on your `api.js`:

```
module.exports = function({ post, put, accept }){
// Define global accept rules
this.accept(['json', 'text/html']);
// Obtain accepts settings
let json = accept('json');
let img = accept([ 'png', 'jpg', 'svg', 'image/*' ]);
// Prepend accept definition in each route chain
post('/my/json/thing', json, myJSONHandler);
post('/my/img/thing', img, myImageHandler);
}
```

### API Description

Nodecaf allows you to descibe your api and it's functionality, effectively turning
Expand Down
17 changes: 4 additions & 13 deletions lib/app-server.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
const fs = require('fs');
const http = require('http');
const https = require('https');
const assert = require('assert');
const mime = require('mime/lite');
const express = require('express');
const compression = require('compression');

const { defaultErrorHandler, addRoute, getAcceptMiddleware } = require('./route-adapter');
const { defaultErrorHandler, addRoute } = require('./route-adapter');
const { parseTypes } = require('./parse-types');
const setupLogger = require('./logger');
const errors = require('./errors');
const HTTP_VERBS = ['get', 'post', 'patch', 'put', 'head'];

mime.define({
'application/x-www-form-urlencoded': ['urlencoded'],
'multipart/form-data': ['form']
});

const noop = Function.prototype;

function routeNotFoundHandler(req, res, next){
Expand Down Expand Up @@ -50,7 +44,7 @@ module.exports = class AppServer {
({ ...o, [method]: addRoute.bind(this, method) }), {});
this.routerFuncs.info = noop;

this.routerFuncs.accept = getAcceptMiddleware.bind(this);
this.routerFuncs.accept = types => ({ accept: parseTypes(types) });

// Create delet special case method.
this.routerFuncs.del = addRoute.bind(this, 'delete');
Expand All @@ -62,10 +56,7 @@ module.exports = class AppServer {
of @types. May be overriden by route specific accepts.
\o */
accept(types){
types = typeof types == 'string' ? [types] : types;
assert(Array.isArray(types));
this.accepts = types.map( t =>
mime.getType(t) || t);
this.accepts = parseTypes(types);
}

/* o\
Expand Down
4 changes: 2 additions & 2 deletions lib/cli/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ module.exports = function init(input){
input = input || cli.parse({
path: [ 'p', 'Project root directory (defaults to working dir)', 'file', undefined ],
confPath: [ 'c', 'Conf file path', 'file', undefined ],
confType: [ false, 'Conf file extension', 'as-is', 'toml' ],
name: [ 'n', 'A name/title for the app', 'as-is', undefined ]
confType: [ false, 'Conf file extension', 'string', 'toml' ],
name: [ 'n', 'A name/title for the app', 'string', undefined ]
});

let projDir = path.resolve(process.cwd(), input.path || '.');
Expand Down
4 changes: 2 additions & 2 deletions lib/cli/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ module.exports = function openapi(input){
input = input || cli.parse({
path: [ 'p', 'Project root directory (defaults to working dir)', 'file', undefined ],
apiPath: [ false, 'The path to your API file (defaults to ./lib/api.js)', 'file', './lib/api.js' ],
type: [ 't', 'A type of output file [yaml || json] (defaults to json)', 'as-is', 'yaml' ],
type: [ 't', 'A type of output file [yaml || json] (defaults to json)', 'string', 'yaml' ],
confPath: [ 'c', 'Conf file path', 'file', undefined ],
confType: [ false, 'Conf file extension', 'as-is', 'toml' ],
confType: [ false, 'Conf file extension', 'string', 'toml' ],
outFile: [ 'o', 'Output file (required)', 'file', undefined ]
});

Expand Down
124 changes: 103 additions & 21 deletions lib/open-api.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const express = require('express');

const { parseTypes } = require('./parse-types');
const HTTP_VERBS = ['get', 'post', 'patch', 'put', 'head'];

/* o\
Expand All @@ -8,10 +10,79 @@ function buildDefaultRESTResponses(){
return {
ServerFault: {
description: 'The server has faced an error state caused by unknown reasons.'
},
Success: {
description: 'The request has been processed without any issues'
}
}
}

function buildDefaultSchemas(){
return {
MissingType: { type: 'string', description: 'Missing \'Content-Type\' header' },
BadType: { type: 'string', description: 'Unsupported content type' }
}
}

function buildResponses(opts){
let r = {
500: { $ref: '#/components/responses/ServerFault' },
200: { $ref: '#/components/responses/Success' }
};

if(opts.accept)
r[400] = {
description: 'Bad Request',
content: {
'text/plain': {
schema: {
oneOf: [
{ '$ref': '#/components/schemas/MissingType' },
{ '$ref': '#/components/schemas/BadType' }
]
}
}
}
};

return r;
}

function buildDefaultRequestBody(){
return {
description: 'Any request body type/format.',
content: { '*/*': {} }
}
}

function buildCustomRequestBodies(accepts){
return {
description: 'Accepts the following types: ' + accepts.join(', '),
content: accepts.reduce((a, c) => ({ ...a, [c]: {} }), {})
}
}

function parseRouteHandlers(handlers){
let opts = {};

for(let h of handlers)
if(typeof h == 'object')
opts = { ...opts, ...h };

return opts;
}

function parsePathParams(params){
return params.map(k => ({
name: k.name,
in: 'path',
description: k.description || '',
required: true,
deprecated: k.deprecated || false,
schema: { type: 'string' }
}));
}

/* o\
Add summary and description to a route.
\o */
Expand All @@ -26,34 +97,34 @@ function describeOp(path, method, text){
/* o\
Add a new route to the doc spec.
\o */
function addOp(method, path){
function addOp(method, path, ...handlers){
// this => app
let paths = this.paths;
paths[path] = paths[path] || {};

// Reference the global responses used.
paths[path][method] = {
responses: {
500: { $ref: '#/components/responses/ServerFault' }
}
let p = this.paths[path] || {};
this.paths[path] = p;

// Asseble reqests and responses data.
let opts = parseRouteHandlers(handlers);
let responses = buildResponses(opts);
let accs = opts.accept || this.accepts;
let reqBody = !accs ? buildDefaultRequestBody() : buildCustomRequestBodies(accs);

// Asseble basic operation object.
p[method] = {
responses: responses,
requestBody: reqBody
};

if(method in { get: true, head: true, delete: true })
delete p[method].requestBody;

// Add express route.
this.router[method](path, Function.prototype);

// Add all path variables as parameters.
this.router.stack.forEach(l => {
if(l.route.path !== path || paths[path].parameters)
if(l.route.path !== path || p.parameters)
return;

paths[path].parameters = l.keys.map(k => ({
name: k.name,
in: 'path',
description: k.description || '',
required: true,
deprecated: k.deprecated || false,
schema: { type: 'string' }
}));
p.parameters = parsePathParams(l.keys);
});

// Return the description function.
Expand Down Expand Up @@ -89,16 +160,26 @@ module.exports = class APIDoc {
// Create delet special case method.
this.routerFuncs.del = addOp.bind(this, 'delete');

this.routerFuncs.accept = types => ({ accept: parseTypes(types) });

// Create function to add Open API info to the API.
this.routerFuncs.info = mergeInfo.bind(this, 'info');
}

/* o\
Define allowed mime-types for request accross the entire app. Can be
overriden by route specific settings.
\o */
accept(types){
this.accepts = parseTypes(types);
}

/* o\
Execute the @callback to define user routes, exposing all REST methods
as arguments.
\o */
api(callback){
callback(this.routerFuncs);
callback.bind(this)(this.routerFuncs);
}

/* o\
Expand All @@ -110,7 +191,8 @@ module.exports = class APIDoc {
info: this.info,
paths: this.paths,
components: {
responses: buildDefaultRESTResponses()
responses: buildDefaultRESTResponses(),
schemas: buildDefaultSchemas()
}
};
}
Expand Down
24 changes: 24 additions & 0 deletions lib/parse-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const assert = require('assert');
const mime = require('mime/lite');

// Define shortcut extensions for supported mime-types.
mime.define({
'application/x-www-form-urlencoded': ['urlencoded'],
'multipart/form-data': ['form']
});

module.exports = {

/* o\
Parse the @types and convert any file extension to it's matching
mime-type. In case @types is a string, a single element array will be
produced.
\o */
parseTypes(types){
types = typeof types == 'string' ? [types] : types;
assert(Array.isArray(types));
types = types.map( t => mime.getType(t) || t);
return types;
}

};
10 changes: 0 additions & 10 deletions lib/route-adapter.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
const os = require('os');
const assert = require('assert');
const express = require('express');
const getRawBody = require('raw-body');
const contentType = require('content-type');
const fileUpload = require('express-fileupload');
const mime = require('mime/lite');

const adaptErrors = require('./a-sync-error-adapter');
const errors = require('./errors');
Expand Down Expand Up @@ -87,14 +85,6 @@ function sendError(err, msg){

module.exports = {

getAcceptMiddleware(types){
// this => app
types = typeof types == 'string' ? [types] : types;
assert(Array.isArray(types));
types = types.map( t => mime.getType(t) || t);
return { accept: types };
},

addRoute(method, path, ...route){
// this => app

Expand Down
Loading

0 comments on commit 9a44417

Please sign in to comment.