Skip to content

Commit

Permalink
refactor validation middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
Carmine DiMascio committed Mar 24, 2019
1 parent 4b6c415 commit f0754f6
Show file tree
Hide file tree
Showing 7 changed files with 48 additions and 414 deletions.
6 changes: 4 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@

- throw error if path param id's don't match
Note: app.params will not have registered for an unknown path param i.e. when express defines a different path name param than express (we should attempt to detect this and flag it)
- add tests with an indepently defined router
- throw error (e.g 404) when route is defined in express but not in openapi spec
- (done) add tests with an indepently defined router
- (done) throw error (e.g 404) when route is defined in express but not in openapi spec
- test with merge middleware
- test with case insensitive and case sensitive routing
239 changes: 25 additions & 214 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,249 +1,60 @@
import * as _ from 'lodash';
import { ExpressApp } from 'express';
import OpenAPIFramework, {
OpenAPIFrameworkArgs,
OpenAPIFrameworkConstructorArgs,
} from './framework';
import OpenAPIRequestValidator from 'openapi-request-validator';
import OpenAPIRequestCoercer from 'openapi-request-coercer';
import { OpenAPIFrameworkAPIContext } from './framework/types';
import { methodNotAllowed, notFoundError } from './errors';

import { OpenAPIFrameworkArgs } from './framework';
import { OpenApiContext } from './openapi.context';
import * as middlewares from './middlewares';
// import { OpenAPIResponseValidatorError } from 'openapi-response-validator';
// import { SecurityHandlers } from 'openapi-security-handler';
// import { OpenAPI, OpenAPIV3 } from 'openapi-types';

export interface ErrorResponse {
statusCode: number;
error: any;
}

export interface OpenApiMiddlewareOpts extends OpenAPIFrameworkArgs {
name: string;
apiSpecPath: string;
errorTransform?: (validationResult: any) => ErrorResponse;
errorTransformer?: (validationResult: any) => ErrorResponse;
}

export function OpenApiMiddleware(opts: OpenApiMiddlewareOpts) {
if (!opts.apiSpecPath) throw new Error('apiSpecPath required');

opts.enableObjectCoercion = opts.enableObjectCoercion || true;
opts.name = opts.name || 'express-middleware-openapi';

const framework = createFramework({ ...opts, apiDoc: opts.apiSpecPath });
const contextOpts = { ...opts, apiDoc: opts.apiSpecPath };
const openApiContext = new OpenApiContext(contextOpts);

this.opts = opts;
this.apiDoc = framework.apiDoc;
this.routes = buildRoutes(framework);
this.routeMap = this.routes.reduce((a, r) => {
const routeMethod = a[r.expressRoute];
if (routeMethod) {
routeMethod[r.method] = r.schema;
} else {
a[r.expressRoute] = { [r.method]: r.schema };
}
return a;
}, {});

this.openApiRouteMap = this.routes.reduce((a, r) => {
const routeMethod = a[r.openApiRoute];
const schema = { ...r.schema, expressRoute: r.expressRoute };
if (routeMethod) {
routeMethod[r.method] = schema;
} else {
a[r.openApiRoute] = { [r.method]: schema };
}
return a;
}, {});

this.routeMatchRegex = buildRouteMatchRegex(
this.routes.map(r => r.expressRoute)
);
this.apiDoc = openApiContext.apiDoc;
this.expressRouteMap = openApiContext.expressRouteMap;
this.context = openApiContext;
}

OpenApiMiddleware.prototype.install = function(app: ExpressApp) {
const noPathParamRoutes = [];
const pathParms = [];
for (const route of this.routes) {
if (route.pathParams.length === 0) {
noPathParamRoutes.push(route.expressRoute);
} else {
pathParms.push(...route.pathParams);
const pathParams = [];
for (const route of this.context.routes) {
if (route.pathParams.length > 0) {
pathParams.push(...route.pathParams);
}
}

// install param on routes with paths
// for (const p of _.uniq(pathParms)) {
// app.param(p, this._middleware());
// }
// // install use on routes without paths
// app.all(_.uniq(noPathParamRoutes), this._middleware());

// TODOD add middleware to capture routes not defined in openapi spec and throw not 404
for (const p of _.uniq(pathParams)) {
app.param(p, (req, res, next, value, name) => {
console.log(name, value);
if (req.openapi.pathParams) {
// override path params
req.params[name] = req.openapi.pathParams[name] || req.params[name];
}
next();
});
}
app.use(
middlewares.core(this.opts, this.apiDoc, this.openApiRouteMap),
middlewares.applyOpenApiMetadata(this.context),
middlewares.validateRequest({
apiDoc: this.apiDoc,
loggingKey: this.opts.name,
enableObjectCoercion: this.opts.enableObjectCoercion,
errorTransformer: this.opts.errorTransformer,
})
);

// app.use(unlessDocumented())
};

OpenApiMiddleware.prototype._middleware = function() {
return (req, res, next) => {
const { path: rpath, method, route } = req;
const path = identifyRoutePath(route, rpath);
if (path && method) {
const documentedRoute = this.routeMap[path];
if (!documentedRoute) {
// TODO add option to enable undocumented routes to pass through without 404
// TODO this should not occur as we only set up middleware and params on routes defined in the openapi spec
const { statusCode, error } = this._transformValidationResult(
notFoundError(path)
);
return res.status(statusCode).json(error);
}

const schema = documentedRoute[method.toUpperCase()];
if (!schema) {
const { statusCode, error } = this._transformValidationResult(
methodNotAllowed(path, method)
);
return res.status(statusCode).json(error);
}

// this req matched an openapi route, mark it
req.openapi = {};

// TODO coercer and request validator fail on null parameters
if (!schema.parameters) {
schema.parameters = [];
}

// Check if route is in map (throw error - option to ignore)
if (this.opts.enableObjectCoercion) {
// this modifies the request object with coerced types
new OpenAPIRequestCoercer({
loggingKey: this.opts.name,
enableObjectCoercion: this.opts.enableObjectCoercion,
parameters: schema.parameters,
}).coerce(req);
}

const validationResult = new OpenAPIRequestValidator({
errorTransformer: this.errorTransformer,
parameters: schema.parameters || [],
requestBody: schema.requestBody,
// schemas: this.apiDoc.definitions, // v2
componentSchemas: this.apiDoc.components // v3
? this.apiDoc.components.schemas
: undefined,
}).validate(req);

if (validationResult && validationResult.errors.length > 0) {
const { statusCode, error } = this._transformValidationResult(
validationResult
);
return res.status(statusCode).json(error);
}
}
next();
};
};

OpenApiMiddleware.prototype._transformValidationResult = function(
validationResult
) {
if (validationResult && validationResult.errors.length > 0) {
const transform =
this.opts.errorTransform ||
(v => ({
statusCode: v.status,
error: { errors: v.errors },
}));

return transform(validationResult);
}
};

function unlessDocumented(middleware, documentedRoutes) {
const re = buildRouteMatchRegex(documentedRoutes);
return (req, res, next) => {
const isDocumented = path => re.test(path);
if (isDocumented(req.path)) {
return next();
} else {
return middleware(req, res, next);
}
};
}
function buildRouteMatchRegex(routes) {
const matchers = routes
.map(route => {
return `^${route}/?$`;
})
.join('|');

console.log(matchers);
return new RegExp(`^(?!${matchers})`);

// ^(?!^\/test/?$|^\/best/:id/?$|^\/yo/?$)(.*)$
}
function identifyRoutePath(route, path) {
return Array.isArray(route.path)
? route.path.find(r => r === path)
: route.path || path;
}

function toExpressParams(part) {
return part.replace(/\{([^}]+)}/g, ':$1');
}

function createFramework(args: OpenApiMiddlewareOpts): OpenAPIFramework {
const frameworkArgs: OpenAPIFrameworkConstructorArgs = {
featureType: 'middleware',
name: args.name,
...(args as OpenAPIFrameworkArgs),
};

// console.log(frameworkArgs);
const framework = new OpenAPIFramework(frameworkArgs);
return framework;
}

function buildRoutes(framework) {
const routes = [];
framework.initialize({
visitApi(ctx: OpenAPIFrameworkAPIContext) {
const apiDoc = ctx.getApiDoc();
for (const bp of ctx.basePaths) {
for (const [path, methods] of Object.entries(apiDoc.paths)) {
for (const [method, schema] of Object.entries(methods)) {
const pathParams = new Set();
for (const param of schema.parameters || []) {
if (param.in === 'path') {
pathParams.add(param.name);
}
}
const openApiRoute = `${path}`;
const expressRoute = `${bp.path}${openApiRoute}`
.split('/')
.map(toExpressParams)
.join('/');
routes.push({
expressRoute,
openApiRoute,
method: method.toUpperCase(),
pathParams: Array.from(pathParams),
schema,
});
}
}
}
},
});
return routes;
}
Loading

0 comments on commit f0754f6

Please sign in to comment.