diff --git a/README.md b/README.md index 917c28c..df3cc7d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/lib/app-server.js b/lib/app-server.js index fd7aa40..5ed1eec 100644 --- a/lib/app-server.js +++ b/lib/app-server.js @@ -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){ @@ -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'); @@ -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\ diff --git a/lib/cli/init.js b/lib/cli/init.js index 0c042a9..a9b9005 100644 --- a/lib/cli/init.js +++ b/lib/cli/init.js @@ -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 || '.'); diff --git a/lib/cli/openapi.js b/lib/cli/openapi.js index da540c9..a13763a 100644 --- a/lib/cli/openapi.js +++ b/lib/cli/openapi.js @@ -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 ] }); diff --git a/lib/open-api.js b/lib/open-api.js index 593e75f..13c5177 100644 --- a/lib/open-api.js +++ b/lib/open-api.js @@ -1,4 +1,6 @@ const express = require('express'); + +const { parseTypes } = require('./parse-types'); const HTTP_VERBS = ['get', 'post', 'patch', 'put', 'head']; /* o\ @@ -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 */ @@ -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. @@ -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\ @@ -110,7 +191,8 @@ module.exports = class APIDoc { info: this.info, paths: this.paths, components: { - responses: buildDefaultRESTResponses() + responses: buildDefaultRESTResponses(), + schemas: buildDefaultSchemas() } }; } diff --git a/lib/parse-types.js b/lib/parse-types.js new file mode 100644 index 0000000..1c0f736 --- /dev/null +++ b/lib/parse-types.js @@ -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; + } + +}; diff --git a/lib/route-adapter.js b/lib/route-adapter.js index 065ad9a..6becf8d 100644 --- a/lib/route-adapter.js +++ b/lib/route-adapter.js @@ -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'); @@ -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 diff --git a/test/spec.js b/test/spec.js index bb11972..9b47d0d 100644 --- a/test/spec.js +++ b/test/spec.js @@ -1,6 +1,9 @@ //const wtf = require('wtfnode'); const assert = require('assert'); +// Address for the tests' local servers to listen. +const LOCAL_HOST = 'http://localhost:80/' + describe('Conf Loader', () => { const loadConf = require('../lib/conf-loader'); @@ -71,7 +74,7 @@ describe('AppServer', () => { it('Should start the http server on port 80', async () => { let app = new AppServer(); await app.start(); - let { status } = await get('http://127.0.0.1:80/'); + let { status } = await get(LOCAL_HOST); assert.strictEqual(status, 404); await app.stop(); }); @@ -119,7 +122,7 @@ describe('AppServer', () => { assert.strictEqual(typeof head, 'function'); }); await app.start(); - let { status } = await post('http://127.0.0.1:80/foo'); + let { status } = await post(LOCAL_HOST + 'foo'); assert.strictEqual(status, 500); await app.stop(); }); @@ -133,7 +136,7 @@ describe('AppServer', () => { ({ flash, res }) => res.end(flash.foo) ); }); await app.start(); - let { body } = await get('http://127.0.0.1:80/bar'); + let { body } = await get(LOCAL_HOST + 'bar'); assert.strictEqual(body, 'bar'); await app.stop(); }); @@ -149,7 +152,7 @@ describe('AppServer', () => { post('/bar', ({ foo, res }) => res.end(foo)); }); await app.start(); - let { body } = await post('http://127.0.0.1:80/bar'); + let { body } = await post(LOCAL_HOST + 'bar'); assert.strictEqual(body, 'foobar'); await app.stop(); }); @@ -163,7 +166,7 @@ describe('AppServer', () => { await app.start(); await app.stop(); try{ - await get('http://127.0.0.1:80/'); + await get(LOCAL_HOST); } catch(e){ var rejected = e; @@ -194,10 +197,10 @@ describe('AppServer', () => { it('Should take down the sever and bring it back up', async () => { let app = new AppServer(); await app.start(); - let { status } = await get('http://127.0.0.1:80/'); + let { status } = await get(LOCAL_HOST); assert.strictEqual(status, 404); await app.restart(); - let { status: s } = await get('http://127.0.0.1:80/'); + let { status: s } = await get(LOCAL_HOST); assert.strictEqual(s, 404); await app.stop(); }); @@ -216,7 +219,7 @@ describe('AppServer', () => { }); await app.start(); let { body, status } = await post( - 'http://localhost/foo', + LOCAL_HOST + 'foo', { 'Content-Type': 'application/json' }, '{"foo":"bar"}' ); @@ -233,7 +236,7 @@ describe('AppServer', () => { }); await app.start(); let { body, status } = await post( - 'http://localhost/foo', + LOCAL_HOST + 'foo', { '--no-auto': true }, '{"foo":"bar"}' ); @@ -250,7 +253,7 @@ describe('AppServer', () => { }); await app.start(); let { status } = await post( - 'http://localhost/foo', + LOCAL_HOST + 'foo', { 'Content-Type': 'text/html' }, '{"foo":"bar"}' ); @@ -294,7 +297,7 @@ describe('REST/Restify Features', () => { }); }); await app.start(); - let { status } = await get('http://localhost:80/foo'); + let { status } = await get(LOCAL_HOST + 'foo'); assert.strictEqual(status, 200); await app.stop(); }); @@ -315,7 +318,7 @@ describe('REST/Restify Features', () => { form.append('foo', 'bar'); form.append('foobar', fs.createReadStream('./test/res/file.txt')); await new Promise(resolve => - form.submit('http://localhost/bar/', (err, res) => { + form.submit(LOCAL_HOST + 'bar/', (err, res) => { assert(res.headers['x-test'] == 'file.txt'); resolve(); }) @@ -334,7 +337,7 @@ describe('REST/Restify Features', () => { }); await app.start(); let { status } = await post( - 'http://localhost:80/foobar', + LOCAL_HOST + 'foobar', { 'Content-Type': 'application/json' }, JSON.stringify({foo: 'bar'}) ); @@ -352,7 +355,7 @@ describe('REST/Restify Features', () => { }); await app.start(); let { status } = await post( - 'http://localhost:80/foobar', + LOCAL_HOST + 'foobar', { '--no-auto': true }, JSON.stringify({foo: 'bar'}) ); @@ -370,7 +373,7 @@ describe('REST/Restify Features', () => { }); await app.start(); let { status } = await post( - 'http://localhost:80/foobar', + LOCAL_HOST + 'foobar', { 'Content-Type': 'application/x-www-form-urlencoded' }, 'foo=bar' ); @@ -387,7 +390,7 @@ describe('REST/Restify Features', () => { }); }); await app.start(); - let { status } = await post('http://localhost:80/foobar?foo=bar'); + let { status } = await post(LOCAL_HOST + 'foobar?foo=bar'); assert.strictEqual(status, 200); await app.stop(); }); @@ -396,7 +399,7 @@ describe('REST/Restify Features', () => { let app = new AppServer(); app.api(function(){ }); await app.start(); - let { status, body } = await post('http://localhost/foobar'); + let { status, body } = await post(LOCAL_HOST + 'foobar'); assert.strictEqual(status, 404); assert.strictEqual(body, ''); await app.stop(); @@ -410,7 +413,7 @@ describe('REST/Restify Features', () => { }); }); await app.start(); - let { body } = await post('http://localhost:80/foobar'); + let { body } = await post(LOCAL_HOST + 'foobar'); assert.doesNotThrow( () => JSON.parse(body) ); await app.stop(); }); @@ -426,7 +429,7 @@ describe('REST/Restify Features', () => { }); await app.start(); let { body, status } = await post( - 'http://localhost/foo', + LOCAL_HOST + 'foo', { 'Content-Type': 'application/json' }, '{"foo":"bar"}' ); @@ -444,7 +447,7 @@ describe('REST/Restify Features', () => { }); await app.start(); let { status } = await post( - 'http://localhost/foo', + LOCAL_HOST + 'foo', { 'Content-Type': 'text/html' }, '{"foo":"bar"}' ); @@ -480,7 +483,7 @@ describe('run()', () => { }); return app; } }); - let { body } = await get('http://127.0.0.1:80/bar'); + let { body } = await get(LOCAL_HOST + 'bar'); assert.strictEqual(body, 'foo'); await app.stop(); }); @@ -533,7 +536,7 @@ describe('Error Handling', () => { }); }); await app.start(); - let { status: status } = await post('http://localhost:80/unknown'); + let { status: status } = await post(LOCAL_HOST + 'unknown'); assert.strictEqual(status, 500); await app.stop(); }); @@ -549,9 +552,9 @@ describe('Error Handling', () => { }); }); await app.start(); - let { status } = await post('http://localhost:80/known'); + let { status } = await post(LOCAL_HOST + 'known'); assert.strictEqual(status, 404); - let { status: s2 } = await post('http://localhost:80/unknown'); + let { status: s2 } = await post(LOCAL_HOST + 'unknown'); assert.strictEqual(s2, 500); await app.stop(); }); @@ -575,11 +578,11 @@ describe('Error Handling', () => { }); }); await app.start(); - let { status } = await post('http://localhost:80/known'); + let { status } = await post(LOCAL_HOST + 'known'); assert.strictEqual(status, 404); - let { status: s2 } = await post('http://localhost:80/unknown'); + let { status: s2 } = await post(LOCAL_HOST + 'unknown'); assert.strictEqual(s2, 500); - let { status: s3 } = await post('http://localhost:80/unknown/object'); + let { status: s3 } = await post(LOCAL_HOST + 'unknown/object'); assert.strictEqual(s3, 500); await app.stop(); }); @@ -600,9 +603,9 @@ describe('Error Handling', () => { }); }); await app.start(); - let { status } = await post('http://localhost:80/known'); + let { status } = await post(LOCAL_HOST + 'known'); assert.strictEqual(status, 500); - let { status: s2 } = await post('http://localhost:80/unknown'); + let { status: s2 } = await post(LOCAL_HOST + 'unknown'); assert.strictEqual(s2, 404); assert.strictEqual(count, 2); await app.stop(); @@ -619,7 +622,7 @@ describe('Error Handling', () => { }); }); await app.start(); - let { status } = await post('http://localhost/unknown'); + let { status } = await post(LOCAL_HOST + 'unknown'); assert.strictEqual(status, 401); await app.stop(); }); @@ -649,7 +652,7 @@ describe('Logging', () => { }); }); await app.start(); - await post('http://localhost:80/foo'); + await post(LOCAL_HOST + 'foo'); let data = await fs.promises.readFile(file, 'utf-8'); assert(data.indexOf('logfile') > 0); await app.stop(); @@ -666,7 +669,7 @@ describe('Logging', () => { }); }); await app.start(); - await post('http://localhost:80/foo'); + await post(LOCAL_HOST + 'foo'); let data = await fs.promises.readFile(file, 'utf-8'); assert(data.indexOf('logstream') > 0); await app.stop(); @@ -680,7 +683,7 @@ describe('Logging', () => { post('/foo', ({ res }) => res.end() ); }); await app.start(); - await post('http://localhost:80/foo'); + await post(LOCAL_HOST + 'foo'); let data = await fs.promises.readFile(file, 'utf-8'); assert(data.indexOf('POST') > 0); await app.stop(); @@ -694,7 +697,7 @@ describe('Logging', () => { post('/foo', () => { throw new Error('Oh yeah') } ); }); await app.start(); - await post('http://localhost/foo'); + await post(LOCAL_HOST + 'foo'); let data = await fs.promises.readFile(file, 'utf-8'); assert(data.indexOf('Oh yeah') > 0); await app.stop(); @@ -730,12 +733,12 @@ describe('API Docs', () => { }); await app.start(); - let { body } = await post('http://localhost:80/foo/baz'); + let { body } = await post(LOCAL_HOST + 'foo/baz'); assert.strictEqual(body, 'OK'); await app.stop(); }); - it('Should have app name and verison by default', function(){ + it('Should have app name and version by default', function(){ let doc = new APIDoc(); let spec = doc.spec(); assert.strictEqual(typeof spec.info.title, 'string'); @@ -770,6 +773,41 @@ describe('API Docs', () => { let spec = doc.spec(); assert.strictEqual(spec.paths['/foo/:bar'].parameters[0].name, 'bar'); }); + + it('Should auto-populate operation with permissive requests body', function(){ + let doc = new APIDoc(); + doc.api( ({ post }) => { + post('/foo', function(){}); + post('/baz', function(){}); + }); + let spec = doc.spec(); + assert.strictEqual(typeof spec.paths['/foo'].post.requestBody, 'object'); + assert('*/*' in spec.paths['/foo'].post.requestBody.content); + }); + + it('Should add request body types based on app accepts', function(){ + let doc = new APIDoc(); + doc.api( function({ post }){ + this.accept(['json', 'text/html']); + post('/foo', function(){}); + }); + let spec = doc.spec(); + assert(/following types/.test(spec.paths['/foo'].post.requestBody.description)); + assert('application/json' in spec.paths['/foo'].post.requestBody.content); + assert('text/html' in spec.paths['/foo'].post.requestBody.content); + }); + + it('Should add request body types based on route accepts', function(){ + let doc = new APIDoc(); + doc.api( function({ post, accept }){ + let acc = accept('json'); + post('/foo', acc, function(){}); + }); + let spec = doc.spec(); + assert(/following types/.test(spec.paths['/foo'].post.requestBody.description)); + assert('application/json' in spec.paths['/foo'].post.requestBody.content); + }); + }); describe('HTTPS', () => { @@ -815,7 +853,7 @@ describe('Regression', () => { }); }); await app.start(); - let { status } = await post('http://localhost:80/bar'); + let { status } = await post(LOCAL_HOST + 'bar'); assert.strictEqual(status, 500); await app.stop(); }); @@ -830,9 +868,9 @@ describe('Regression', () => { }); await app.start(); - let r1 = (await post('http://localhost:80/bar')).body; - let r2 = (await post('http://localhost:80/bar')).body; - let r3 = (await post('http://localhost:80/bar')).body; + let r1 = (await post(LOCAL_HOST + 'bar')).body; + let r2 = (await post(LOCAL_HOST + 'bar')).body; + let r3 = (await post(LOCAL_HOST + 'bar')).body; assert(r1 == r2 && r2 == r3 && r3 == 'errfoobar'); await app.stop(); @@ -848,7 +886,7 @@ describe('Regression', () => { }); await app.start(); - let m = (await post('http://localhost:80/bar')).body; + let m = (await post(LOCAL_HOST + 'bar')).body; assert.strictEqual(m, 'NotFound'); await app.stop(); @@ -867,7 +905,7 @@ describe('Regression', () => { gotHere = true; }; await app.start(); - await post('http://localhost:80/bar'); + await post(LOCAL_HOST + 'bar'); await app.stop(); assert(gotHere); });