openapi-backend
Advanced tools
Comparing version
@@ -0,1 +1,3 @@ | ||
import _ from 'lodash'; | ||
import Ajv from 'ajv'; | ||
import { OpenAPIV3 } from 'openapi-types'; | ||
@@ -25,8 +27,23 @@ declare type Handler = (...args: any[]) => Promise<any>; | ||
}; | ||
export declare function parseRequest(req: RequestObject, path?: string): { | ||
params: _.Dictionary<string>; | ||
cookies: {}; | ||
requestBody: any; | ||
method: string; | ||
path: string; | ||
headers: { | ||
[key: string]: string | string[]; | ||
}; | ||
query?: string | { | ||
[key: string]: string | string[]; | ||
}; | ||
body?: any; | ||
}; | ||
export declare function validateDefinition(definition: OpenAPIV3.Document): OpenAPIV3.Document; | ||
interface ConstructorOpts { | ||
document: OpenAPIV3.Document; | ||
definition: OpenAPIV3.Document | string; | ||
strict?: boolean; | ||
validate?: boolean; | ||
handlers?: { | ||
[operationId: string]: Handler; | ||
[handler: string]: Handler; | ||
}; | ||
@@ -37,11 +54,70 @@ } | ||
strict: boolean; | ||
validate: boolean; | ||
handlers: { | ||
[operationId: string]: Handler; | ||
}; | ||
initalized: boolean; | ||
private document; | ||
private internalHandlers; | ||
constructor(opts: ConstructorOpts); | ||
validateRequest(req: RequestObject): Promise<boolean>; | ||
init(): Promise<this>; | ||
handleRequest(req: RequestObject, ...handlerArgs: any[]): Promise<any>; | ||
getOperation(operationId: string): OpenAPIV3.OperationObject; | ||
matchOperation(req: RequestObject): OpenAPIV3.OperationObject; | ||
validateRequest(req: RequestObject): { | ||
ajv: Ajv.Ajv; | ||
valid: boolean | Ajv.Thenable<any>; | ||
}; | ||
getOperations(): { | ||
tags?: string[]; | ||
summary?: string; | ||
description?: string; | ||
externalDocs?: OpenAPIV3.ExternalDocumentationObject; | ||
operationId?: string; | ||
parameters?: (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[]; | ||
requestBody?: OpenAPIV3.ReferenceObject | OpenAPIV3.RequestBodyObject; | ||
responses?: OpenAPIV3.ResponsesObject; | ||
callbacks?: { | ||
[callback: string]: OpenAPIV3.ReferenceObject | OpenAPIV3.CallbackObject; | ||
}; | ||
deprecated?: boolean; | ||
security?: OpenAPIV3.SecurityRequirementObject[]; | ||
servers?: OpenAPIV3.ServerObject[]; | ||
path: string; | ||
method: string; | ||
}[]; | ||
getOperation(operationId: string): { | ||
tags?: string[]; | ||
summary?: string; | ||
description?: string; | ||
externalDocs?: OpenAPIV3.ExternalDocumentationObject; | ||
operationId?: string; | ||
parameters?: (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[]; | ||
requestBody?: OpenAPIV3.ReferenceObject | OpenAPIV3.RequestBodyObject; | ||
responses?: OpenAPIV3.ResponsesObject; | ||
callbacks?: { | ||
[callback: string]: OpenAPIV3.ReferenceObject | OpenAPIV3.CallbackObject; | ||
}; | ||
deprecated?: boolean; | ||
security?: OpenAPIV3.SecurityRequirementObject[]; | ||
servers?: OpenAPIV3.ServerObject[]; | ||
path: string; | ||
method: string; | ||
}; | ||
matchOperation(req: RequestObject): { | ||
tags?: string[]; | ||
summary?: string; | ||
description?: string; | ||
externalDocs?: OpenAPIV3.ExternalDocumentationObject; | ||
operationId?: string; | ||
parameters?: (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[]; | ||
requestBody?: OpenAPIV3.ReferenceObject | OpenAPIV3.RequestBodyObject; | ||
responses?: OpenAPIV3.ResponsesObject; | ||
callbacks?: { | ||
[callback: string]: OpenAPIV3.ReferenceObject | OpenAPIV3.CallbackObject; | ||
}; | ||
deprecated?: boolean; | ||
security?: OpenAPIV3.SecurityRequirementObject[]; | ||
servers?: OpenAPIV3.ServerObject[]; | ||
path: string; | ||
method: string; | ||
}; | ||
register(handlers: { | ||
@@ -48,0 +124,0 @@ [operationId: string]: Handler; |
258
index.js
@@ -53,5 +53,12 @@ "use strict"; | ||
var lodash_1 = __importDefault(require("lodash")); | ||
var ajv_1 = __importDefault(require("ajv")); | ||
var swagger_parser_1 = __importDefault(require("swagger-parser")); | ||
var openapi_schema_validation_1 = require("openapi-schema-validation"); | ||
// normalises request | ||
// - http method is lowercase | ||
// - path leading slash 👍 | ||
// - path trailing slash 👎 | ||
// - path query string 👎 | ||
function normalizeRequest(req) { | ||
return __assign({}, req, { path: req.path | ||
return __assign({}, req, { path: (req.path || '') | ||
.trim() | ||
@@ -63,2 +70,33 @@ .split('?')[0] // remove query string | ||
exports.normalizeRequest = normalizeRequest; | ||
// parses request | ||
// - parse json body | ||
// - parse path params based on uri template | ||
// - parse query string | ||
// - parse cookies from headers | ||
function parseRequest(req, path) { | ||
var requestBody; | ||
try { | ||
requestBody = JSON.parse(req.body); | ||
} | ||
catch (_a) { | ||
// suppress json parsing errors | ||
} | ||
// @TODO: parse query string from req.path + req.query | ||
// @TODO: parse cookie from headers | ||
var cookies = {}; | ||
// normalize | ||
req = normalizeRequest(req); | ||
// parse path | ||
var paramPlaceholder = '{[^\\/]*}'; | ||
var pathPattern = "^" + path.replace(new RegExp(paramPlaceholder, 'g'), '([^\\/]+)').replace(/\//g, '\\/') + "$"; | ||
var paramValueArray = new RegExp(pathPattern).exec(req.path).splice(1); | ||
var paramNameArray = (path.match(new RegExp(paramPlaceholder, 'g')) || []).map(function (param) { | ||
return param.replace(/[{}]/g, ''); | ||
}); | ||
var params = lodash_1.default.zipObject(paramNameArray, paramValueArray); | ||
return __assign({}, req, { params: params, | ||
cookies: cookies, | ||
requestBody: requestBody }); | ||
} | ||
exports.parseRequest = parseRequest; | ||
function validateDefinition(definition) { | ||
@@ -75,32 +113,45 @@ var _a = openapi_schema_validation_1.validate(definition, 3), valid = _a.valid, errors = _a.errors; | ||
function OpenAPIBackend(opts) { | ||
this.internalHandlers = ['404', 'notFound', '501', 'notImplemented']; | ||
this.definition = opts.document; | ||
this.internalHandlers = ['404', 'notFound', '501', 'notImplemented', '400', 'validationFail']; | ||
this.document = opts.definition; | ||
this.strict = Boolean(opts.strict); | ||
this.handlers = {}; | ||
// validate definition | ||
try { | ||
validateDefinition(this.definition); | ||
} | ||
catch (err) { | ||
if (this.strict) { | ||
// in strict-mode, fail hard and re-throw the error | ||
throw err; | ||
} | ||
else { | ||
// just emit a warning about the validation errors | ||
console.warn(err); | ||
} | ||
} | ||
// register handlers | ||
if (opts.handlers) { | ||
this.register(opts.handlers); | ||
} | ||
this.validate = lodash_1.default.isNil(opts.validate) ? true : opts.validate; | ||
this.handlers = opts.handlers || {}; | ||
} | ||
OpenAPIBackend.prototype.validateRequest = function (req) { | ||
OpenAPIBackend.prototype.init = function () { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var operation; | ||
return __generator(this, function (_a) { | ||
operation = this.matchOperation(req); | ||
// @TODO: perform Ajv validation for input | ||
return [2 /*return*/, true]; | ||
var _a, err_1; | ||
return __generator(this, function (_b) { | ||
switch (_b.label) { | ||
case 0: | ||
if (!!this.definition) return [3 /*break*/, 4]; | ||
_b.label = 1; | ||
case 1: | ||
_b.trys.push([1, 3, , 4]); | ||
// load, dereference and valdidate definition | ||
_a = this; | ||
return [4 /*yield*/, swagger_parser_1.default.dereference(this.document)]; | ||
case 2: | ||
// load, dereference and valdidate definition | ||
_a.definition = _b.sent(); | ||
validateDefinition(this.definition); | ||
return [3 /*break*/, 4]; | ||
case 3: | ||
err_1 = _b.sent(); | ||
if (this.strict) { | ||
// in strict-mode, fail hard and re-throw the error | ||
throw err_1; | ||
} | ||
else { | ||
// just emit a warning about the validation errors | ||
console.warn(err_1); | ||
} | ||
return [3 /*break*/, 4]; | ||
case 4: | ||
// register handlers | ||
if (this.handlers) { | ||
this.register(this.handlers); | ||
} | ||
this.initalized = true; | ||
return [2 /*return*/, this]; | ||
} | ||
}); | ||
@@ -115,39 +166,116 @@ }); | ||
return __awaiter(this, void 0, void 0, function () { | ||
var operation, notFoundHandler, operationId, notImplementedHandler, routeHandler; | ||
return __generator(this, function (_a) { | ||
operation = this.matchOperation(req); | ||
if (!operation || !operation.operationId) { | ||
notFoundHandler = this.handlers['404'] || this.handlers['notFound']; | ||
if (!notFoundHandler) { | ||
throw Error("operation not found for request and no 404|notFound handler was registered"); | ||
} | ||
return [2 /*return*/, notFoundHandler.apply(void 0, handlerArgs)]; | ||
var operation, notFoundHandler, operationId, _a, ajv, valid, validationFailHandler, prettyErrors, routeHandler, notImplementedHandler; | ||
return __generator(this, function (_b) { | ||
switch (_b.label) { | ||
case 0: | ||
if (!!this.initalized) return [3 /*break*/, 2]; | ||
// api has not yet been initalised | ||
return [4 /*yield*/, this.init()]; | ||
case 1: | ||
// api has not yet been initalised | ||
_b.sent(); | ||
_b.label = 2; | ||
case 2: | ||
operation = this.matchOperation(req); | ||
if (!operation || !operation.operationId) { | ||
notFoundHandler = this.handlers['404'] || this.handlers['notFound']; | ||
if (!notFoundHandler) { | ||
throw Error("404-notFound: no route matches request"); | ||
} | ||
return [2 /*return*/, notFoundHandler.apply(void 0, handlerArgs)]; | ||
} | ||
operationId = operation.operationId; | ||
if (this.validate) { | ||
_a = this.validateRequest(req), ajv = _a.ajv, valid = _a.valid; | ||
if (!valid) { | ||
validationFailHandler = this.handlers['400'] || this.handlers['validationFail']; | ||
if (!validationFailHandler) { | ||
prettyErrors = JSON.stringify(ajv.errors, null, 2); | ||
throw Error("400-validationFail: " + operationId + ", errors: " + prettyErrors); | ||
} | ||
return [2 /*return*/, validationFailHandler.apply(void 0, [ajv.errors].concat(handlerArgs))]; | ||
} | ||
} | ||
routeHandler = this.handlers[operationId]; | ||
if (!routeHandler) { | ||
notImplementedHandler = this.handlers['501'] || this.handlers['notImplemented']; | ||
if (!notImplementedHandler) { | ||
throw Error("501-notImplemented: " + operationId + " no handler registered"); | ||
} | ||
return [2 /*return*/, notImplementedHandler.apply(void 0, handlerArgs)]; | ||
} | ||
// handle route | ||
return [2 /*return*/, routeHandler.apply(void 0, handlerArgs)]; | ||
} | ||
operationId = operation.operationId; | ||
if (!operationId || !this.handlers[operationId]) { | ||
notImplementedHandler = this.handlers['501'] || this.handlers['notImplemented']; | ||
if (!notImplementedHandler) { | ||
throw Error("no handler registered for " + operationId + " and no 501|notImplemented handler was registered"); | ||
} | ||
return [2 /*return*/, notImplementedHandler.apply(void 0, handlerArgs)]; | ||
} | ||
routeHandler = this.handlers[operationId]; | ||
return [2 /*return*/, routeHandler.apply(void 0, handlerArgs)]; | ||
}); | ||
}); | ||
}; | ||
OpenAPIBackend.prototype.getOperation = function (operationId) { | ||
return (lodash_1.default.chain(this.definition.paths) | ||
// flatten operations into an array | ||
.values() | ||
.flatMap(lodash_1.default.values) | ||
// match operationId | ||
.find({ operationId: operationId }) | ||
.value()); | ||
OpenAPIBackend.prototype.validateRequest = function (req) { | ||
var ajv = new ajv_1.default({ | ||
coerceTypes: true, | ||
}); | ||
var operation = this.matchOperation(req); | ||
var _a = parseRequest(req, operation.path), params = _a.params, query = _a.query, headers = _a.headers, cookies = _a.cookies, requestBody = _a.requestBody; | ||
var parameters = lodash_1.default.omitBy({ | ||
path: params, | ||
query: query, | ||
header: headers, | ||
cookie: cookies, | ||
requestBody: requestBody, | ||
}, lodash_1.default.isNil); | ||
// build input validation schema for operation | ||
// @TODO: pre-build this for each operation for performance | ||
var schema = { | ||
title: 'Request', | ||
type: 'object', | ||
additionalProperties: false, | ||
properties: { | ||
path: { | ||
type: 'object', | ||
additionalProperties: false, | ||
properties: {}, | ||
required: [], | ||
}, | ||
query: { | ||
type: 'object', | ||
properties: {}, | ||
additionalProperties: false, | ||
required: [], | ||
}, | ||
header: { | ||
type: 'object', | ||
additionalProperties: true, | ||
properties: {}, | ||
required: [], | ||
}, | ||
cookie: { | ||
type: 'object', | ||
additionalProperties: true, | ||
properties: {}, | ||
required: [], | ||
}, | ||
}, | ||
}; | ||
// params are dereferenced here, no reference objects. | ||
var operationParameters = operation.parameters || []; | ||
operationParameters.map(function (param) { | ||
var target = schema.properties[param.in]; | ||
if (param.required) { | ||
target.required.push(param.name); | ||
} | ||
target.properties[param.name] = param.schema; | ||
}); | ||
if (operation.requestBody) { | ||
// @TODO: infer most specific media type from headers | ||
var mediaType = 'application/json'; | ||
var jsonbody = operation.requestBody.content[mediaType]; | ||
if (jsonbody && jsonbody.schema) { | ||
schema.properties.requestBody = jsonbody.schema; | ||
} | ||
} | ||
return { ajv: ajv, valid: ajv.validate(schema, parameters) }; | ||
}; | ||
OpenAPIBackend.prototype.matchOperation = function (req) { | ||
// normalize request for matching | ||
req = normalizeRequest(req); | ||
var operations = lodash_1.default.chain(this.definition.paths) | ||
// flatten operations into an array with path + method | ||
// flatten operations into an array with path + method | ||
OpenAPIBackend.prototype.getOperations = function () { | ||
return lodash_1.default.chain(this.definition.paths) | ||
.entries() | ||
@@ -163,2 +291,10 @@ .flatMap(function (_a) { | ||
.value(); | ||
}; | ||
OpenAPIBackend.prototype.getOperation = function (operationId) { | ||
return lodash_1.default.find(this.getOperations(), { operationId: operationId }); | ||
}; | ||
OpenAPIBackend.prototype.matchOperation = function (req) { | ||
// normalize request for matching | ||
req = normalizeRequest(req); | ||
var operations = this.getOperations(); | ||
// first check for an exact match | ||
@@ -179,3 +315,3 @@ var exactMatch = lodash_1.default.find(operations, function (_a) { | ||
// convert openapi path template to a regex pattern. {id} becomes ([^/]+) | ||
var pathPattern = "^" + path.replace(/\{.*\}/g, '([^/]+)').replace(/\//g, '\\/') + "\\/?$"; | ||
var pathPattern = "^" + path.replace(/\{.*\}/g, '([^/]+)').replace(/\//g, '\\/') + "$"; | ||
return Boolean(req.path.match(new RegExp(pathPattern, 'g'))); | ||
@@ -182,0 +318,0 @@ }); |
{ | ||
"name": "openapi-backend", | ||
"description": "Tools for building API backends with the OpenAPI standard", | ||
"version": "0.1.0", | ||
"version": "0.2.0", | ||
"author": "Viljami Kuosmanen <viljami@avoinsorsa.fi>", | ||
@@ -26,2 +26,8 @@ "license": "MIT", | ||
], | ||
"dependencies": { | ||
"@types/lodash": "^4.14.117", | ||
"@types/swagger-parser": "^4.0.2", | ||
"lodash": "^4.17.11", | ||
"swagger-parser": "^6.0.1" | ||
}, | ||
"devDependencies": { | ||
@@ -46,7 +52,3 @@ "@types/jest": "^23.3.7", | ||
"test": "NODE_ENV=test jest" | ||
}, | ||
"dependencies": { | ||
"@types/lodash": "^4.14.117", | ||
"lodash": "^4.17.11" | ||
} | ||
} |
@@ -1,7 +0,17 @@ | ||
# OpenAPI Backend Tools | ||
# OpenAPI Backend | ||
[](https://travis-ci.org/anttiviljami/openapi-backend) | ||
[](https://badge.fury.io/js/openapi-backend) | ||
[](http://anttiviljami.mit-license.org) | ||
Tools for building API backends with the OpenAPI standard | ||
Tools for building API backends with the [OpenAPI standard](https://github.com/OAI/OpenAPI-Specification) | ||
## Features | ||
- Build APIs by describing them in [OpenAPI document specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md) | ||
and importing them via YAML, JSON or as a JavaScript object | ||
- Register handlers for API operations in your favourite Node.js backend like [Express](#express), [Hapi](#hapi), | ||
[AWS Lambda](#aws-serverless-lambda) or [Azure Functions](#azure-serverless-function) | ||
- Use [JSON Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#data-types) to validate | ||
API requests. OpenAPI Backend uses the [AJV](https://ajv.js.org/) library under the hood for performant validation | ||
## Quick Start | ||
@@ -19,4 +29,4 @@ | ||
const api = new OpenAPIBackend({ | ||
document: { | ||
openapi: '3.0.0', | ||
definition: { | ||
openapi: '3.0.2', | ||
info: { | ||
@@ -34,8 +44,2 @@ title: 'My API', | ||
}, | ||
post: { | ||
operationId: 'createPet', | ||
responses: { | ||
201: { description: 'ok' }, | ||
}, | ||
}, | ||
}, | ||
@@ -45,2 +49,12 @@ '/pets/{id}': { | ||
operationId: 'getPetById', | ||
parameters: [ | ||
{ | ||
name: 'id', | ||
in: 'path', | ||
required: true, | ||
schema: { | ||
type: 'integer', | ||
}, | ||
}, | ||
], | ||
responses: { | ||
@@ -50,8 +64,2 @@ 200: { description: 'ok' }, | ||
}, | ||
get: { | ||
operationId: 'deletePetById', | ||
responses: { | ||
200: { description: 'ok' }, | ||
}, | ||
}, | ||
}, | ||
@@ -62,9 +70,11 @@ }, | ||
// your platform specific request handlers here | ||
getPets: async () => { status: 200, body: 'ok' }, | ||
createPet: async () => { status: 201, body: 'ok' }) }, | ||
getPetById: async () => { status: 200, body: 'ok' }) }, | ||
deletePetById: async () => { status: 200, body: 'ok' }) }, | ||
notFound: async () => { status: 404, body: 'not found' }) }, | ||
getPets: async (req) => ({ status: 200, body: 'ok' }), | ||
getPetById: async (req) => ({ status: 200, body: 'ok' }), | ||
notFound: async (req) => ({ status: 404, body: 'not found' }), | ||
validationFail: async (err, req) => ({ status: 400, body: JSON.stringify({ err }) }), | ||
}, | ||
}); | ||
// initalize the backend | ||
api.init(); | ||
``` | ||
@@ -71,0 +81,0 @@ |
26091
57.85%471
81.85%147
7.3%4
100%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added