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 f0754f6 commit 549192e
Show file tree
Hide file tree
Showing 4 changed files with 286 additions and 0 deletions.
56 changes: 56 additions & 0 deletions src/middlewares/openapi.metadata.ts
@@ -0,0 +1,56 @@
const pathToRegexp = require('path-to-regexp');
const _ = require('lodash');
import { OpenApiContext } from '../openapi.context';

export function applyOpenApiMetadata(openApiContext: OpenApiContext) {
return (req, res, next) => {
req.openapi = {};
console.log('applyOpenApiMetadata: applying metadata to request', req.path);
const matched = matchRoute(req);

if (matched) {
const { expressRoute, openApiRoute, pathParams, schema } = matched;
console.log('applyOpenApiMetadata', expressRoute, openApiRoute);
req.openapi.expressRoute = expressRoute;
req.openapi.openApiRoute = openApiRoute;
req.openapi.pathParams = pathParams;
req.openapi.schema = schema;
req.params = pathParams;
}
next();
};

function matchRoute(req) {
const path = req.path;
const method = req.method;
for (const [expressRoute, methods] of Object.entries(
openApiContext.expressRouteMap
)) {
const schema = methods[method];
const routePair = openApiContext.routePair(expressRoute);
const openApiRoute = routePair.openApiRoute;

const keys = [];
const regexp = pathToRegexp(expressRoute, keys);
const matchedRoute = regexp.exec(path);

if (matchedRoute) {
console.log('core_mw: matchRoute', matchedRoute);
console.log('core_mw: matchRoute:keys', keys);
const paramKeys = keys.map(k => k.name);
const paramsVals = matchedRoute.slice(1);
const pathParams = _.zipObject(paramKeys, paramsVals);
console.log('core_mw: create params', pathParams);
return {
schema,
// schema may or may not contain express and openApi routes, so include them here
expressRoute,
openApiRoute,
pathParams,
};
}
}

return null;
}
}
91 changes: 91 additions & 0 deletions src/middlewares/openapi.request.validator.ts
@@ -0,0 +1,91 @@
import OpenAPIRequestValidator from 'openapi-request-validator';
import OpenAPIRequestCoercer from 'openapi-request-coercer';
import { methodNotAllowed, notFoundError } from '../errors';

export function validateRequest({
apiDoc,
loggingKey,
enableObjectCoercion,
errorTransformer,
}) {
return (req, res, next) => {
console.log(
'validateRequest_mw: ',
req.openapi.openApiRoute,
req.openapi.expressRoute
);
const path = req.openapi.expressRoute;
if (!path) {
const err = notFoundError(req.path);
return sendValidationError(res, err, errorTransformer);
}
const schema = req.openapi.schema;
if (!schema) {
// add openapi metadata to make this case more clear
// its not obvious that missig schema means methodNotAllowed
const err = methodNotAllowed(path, req.method);
return sendValidationError(res, err, errorTransformer);
}

if (!schema.parameters) {
schema.parameters = [];
}

const shouldUpdatePathParams =
Object.keys(req.openapi.pathParams).length > 0;

if (shouldUpdatePathParams) {
req.params = req.openapi.pathParams || req.params;
}

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

const validationResult = new OpenAPIRequestValidator({
// errorTransformer, // TODO create custom error transformere here as there are a lot of props we can utilize
parameters: schema.parameters || [],
requestBody: schema.requestBody,
// schemas: this.apiDoc.definitions, // v2
componentSchemas: apiDoc.components // v3
? apiDoc.components.schemas
: undefined,
}).validate(req);

if (validationResult && validationResult.errors.length > 0) {
return sendValidationError(res, validationResult, errorTransformer);
}
next();
};
}

function sendValidationError(res, validationResult, transformer) {
console.log(
'validateRequest_mw: validation error',
validationResult,
transformer
);
if (!validationResult) throw Error('validationResult missing');

const transform =
transformer ||
(v => ({
statusCode: v.status,
// TODO content-type shoudl be set and retuned
error: { errors: v.errors },
}));
const x = transform(validationResult);
if (!x || !x.statusCode || !x.error) {
throw Error(
'invalid error transform. must return an object with shape { statusCode, error}'
);
}
// TODO throw rather than returning a result
return res.status(x.statusCode).json(x.error);
}
64 changes: 64 additions & 0 deletions src/openapi.context.ts
@@ -0,0 +1,64 @@
import { OpenApiSpecLoader } from './openapi.spec.loader';
import { OpenAPIFrameworkArgs } from './framework';

export class OpenApiContext {
// TODO cleanup structure (group related functionality)
expressRouteMap = {};
openApiRouteMap = {};
routes = [];
apiDoc;
constructor(opts: OpenAPIFrameworkArgs) {
const openApiRouteDiscovery = new OpenApiSpecLoader(opts);
const { apiDoc, routes } = openApiRouteDiscovery.load();

this.apiDoc = apiDoc;
this.routes = this.initializeRoutes(routes);
}

private initializeRoutes(routes) {
for (const route of routes) {
const routeMethods = this.expressRouteMap[route.expressRoute];
if (routeMethods) {
routeMethods[route.method] = route.schema;
} else {
const { schema, openApiRoute, expressRoute } = route;
const routeMethod = { [route.method]: schema };
const routeDetails = {
_openApiRoute: openApiRoute,
_expressRoute: expressRoute,
...routeMethod,
};
this.expressRouteMap[route.expressRoute] = routeDetails;
this.openApiRouteMap[route.openApiRoute] = routeDetails;
}
}
return routes;
}

routePair(route) {
const methods = this.methods(route);
if (methods) {
return {
expressRoute: methods._expressRoute,
openApiRoute: methods._openApiRoute,
};
}
return null;
}

methods(route) {
const expressRouteMethods = this.expressRouteMap[route];
if (expressRouteMethods) return expressRouteMethods;
const openApiRouteMethods = this.openApiRouteMap[route];
return openApiRouteMethods;
}

schema(route, method) {
const methods = this.methods(route);
if (methods) {
const schema = methods[method];
return schema;
}
return null;
}
}
75 changes: 75 additions & 0 deletions src/openapi.spec.loader.ts
@@ -0,0 +1,75 @@
import * as _ from 'lodash';

import OpenAPIFramework, {
OpenAPIFrameworkArgs,
OpenAPIFrameworkConstructorArgs,
} from './framework';
import { OpenAPIFrameworkAPIContext } from './framework/types';

export class OpenApiSpecLoader {
private opts: OpenAPIFrameworkArgs;
constructor(opts: OpenAPIFrameworkArgs) {
this.opts = opts;
}

load() {
const framework = this.createFramework(this.opts);
const apiDoc = framework.apiDoc;
const routes = this.discoverRoutes(framework);
return {
apiDoc,
routes,
};
}

private createFramework(args: OpenAPIFrameworkArgs): OpenAPIFramework {
const frameworkArgs: OpenAPIFrameworkConstructorArgs = {
featureType: 'middleware',
name: 'express-middleware-openapi',
...(args as OpenAPIFrameworkArgs),
};

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

private discoverRoutes(framework) {
const routes = [];
const toExpressParams = this.toExpressParams;
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 = `${bp.path}${path}`;
const expressRoute = `${openApiRoute}`
.split('/')
.map(toExpressParams)
.join('/');
routes.push({
expressRoute,
openApiRoute,
method: method.toUpperCase(),
pathParams: Array.from(pathParams),
schema,
});
}
}
}
},
});
return routes;
}

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

0 comments on commit 549192e

Please sign in to comment.