Skip to content

Commit

Permalink
Extract function for parsing operation arguments (#295)
Browse files Browse the repository at this point in the history
  • Loading branch information
rashmihunt authored and bajtos committed May 25, 2017
1 parent 7be3de2 commit 3e25c78
Show file tree
Hide file tree
Showing 7 changed files with 242 additions and 84 deletions.
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"devDependencies": {
"@loopback/openapi-spec-builder": "^4.0.0-alpha.2",
"@loopback/testlab": "^4.0.0-alpha.3",
"@types/shot": "^3.4.0",
"mocha": "^3.2.0",
"shot": "^3.4.0",
"typescript": "^2.3.2"
},
"files": [
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ export * from '@loopback/openapi-spec';

// external dependencies
export {ServerRequest, ServerResponse} from 'http';

// internals used by unit-tests
export {parseOperationArgs} from './parser';
export {ParsedRequest, parseRequestUrl} from './router/SwaggerRouter';
92 changes: 92 additions & 0 deletions packages/core/src/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright IBM Corp. 2017. All Rights Reserved.
// Node module: @loopback/core
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {ServerRequest as Request} from 'http';
import {
PathParameterValues,
OperationArgs,
ParsedRequest,
HttpError,
createHttpError,
} from './router/SwaggerRouter';
import {OperationObject, ParameterObject} from '@loopback/openapi-spec';
import {promisify} from './promisify';

type jsonBodyFn = (req: Request, cb: (err?: Error, body?: {}) => void) => void;
const jsonBody: jsonBodyFn = require('body/json');

// tslint:disable:no-any
type MaybeBody = any | undefined;
// tslint:enable:no-any

const parseJsonBody: (req: Request) => Promise<MaybeBody> = promisify(jsonBody);

export async function parseOperationArgs(request: ParsedRequest, operationSpec: OperationObject, pathParams: PathParameterValues): Promise<OperationArgs> {
const args: OperationArgs = [];
const body = await loadRequestBodyIfNeeded(operationSpec, request);
return buildOperationArguments(operationSpec, request, pathParams, body);
}

function loadRequestBodyIfNeeded(operationSpec: OperationObject, request: Request): Promise<MaybeBody> {
if (!hasArgumentsFromBody(operationSpec))
return Promise.resolve();

const contentType = request.headers['content-type'];
if (contentType && !/json/.test(contentType)) {
const err = createHttpError(415, `Content-type ${contentType} is not supported.`);
return Promise.reject(err);
}

return parseJsonBody(request).catch((err: HttpError) => {
err.statusCode = 400;
return Promise.reject(err);
});
}

function hasArgumentsFromBody(operationSpec: OperationObject): boolean {
if (!operationSpec.parameters || !operationSpec.parameters.length)
return false;

for (const paramSpec of operationSpec.parameters) {
if ('$ref' in paramSpec) continue;
const source = (paramSpec as ParameterObject).in;
if (source === 'formData' || source === 'body')
return true;
}
return false;
}

function buildOperationArguments(operationSpec: OperationObject, request: ParsedRequest,
pathParams: PathParameterValues, body?: MaybeBody): OperationArgs {
const args: OperationArgs = [];

for (const paramSpec of operationSpec.parameters || []) {
if ('$ref' in paramSpec) {
// TODO(bajtos) implement $ref parameters
throw new Error('$ref parameters are not supported yet.');
}
const spec = paramSpec as ParameterObject;
switch (spec.in) {
case 'query':
args.push(request.query[spec.name]);
break;
case 'path':
args.push(pathParams[spec.name]);
break;
case 'header':
args.push(request.headers[spec.name.toLowerCase()]);
break;
case 'formData':
args.push(body ? body[spec.name] : undefined);
break;
case 'body':
args.push(body);
break;
default:
throw createHttpError(501, 'Parameters with "in: ' + spec.in + '" are not supported yet.');
}
}
return args;
}
37 changes: 37 additions & 0 deletions packages/core/src/promisify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright IBM Corp. 2017. All Rights Reserved.
// Node module: @loopback/core
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

// TODO(bajtos) Move this file to a standalone module, or find an existing
// npm module that we could use instead. Just make sure the existing
// module is using native utils.promisify() when available.

// tslint:disable:no-any

import * as util from 'util';

const nativePromisify = (util as any).promisify;

export function promisify<T>(func: (callback: (err: any, result: T) => void) => void): () => Promise<T>;
export function promisify<T, A1>(func: (arg1: A1, callback: (err: any, result: T) => void) => void): (arg1: A1) => Promise<T>;
export function promisify<T, A1, A2>(func: (arg1: A1, arg2: A2, callback: (err: any, result: T) => void) => void): (arg1: A1, arg2: A2) => Promise<T>;

export function promisify<T>(func: (...args: any[]) => void): (...args: any[]) => Promise<T> {
if (nativePromisify)
return nativePromisify(func);

// The simplest implementation of Promisify
return (...args) => {
return new Promise((resolve, reject) => {
try {
func(...args, (err?: any, result?: any) => {
if (err) reject(err);
else resolve(result);
});
} catch (err) {
reject(err);
}
});
};
}
107 changes: 24 additions & 83 deletions packages/core/src/router/SwaggerRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,18 @@
import {ServerRequest as Request, ServerResponse as Response} from 'http';
import {OpenApiSpec, OperationObject, ParameterObject} from '@loopback/openapi-spec';
import {invoke} from '../invoke';
import {parseOperationArgs} from '../parser';
import * as assert from 'assert';
import * as url from 'url';
import * as pathToRegexp from 'path-to-regexp';
import * as Promise from 'bluebird';
const debug = require('debug')('loopback:SwaggerRouter');

type jsonBodyFn = (req: Request, cb: (err?: Error, body?: {}) => void) => void;
const jsonBody: jsonBodyFn = require('body/json');

// tslint:disable:no-any
type MaybeBody = any | undefined;
type OperationArgs = any[];
type PathParameterValues = {[key: string]: any};
export type OperationArgs = any[];
export type PathParameterValues = {[key: string]: any};
type OperationRetval = any;
// tslint:enable:no-any

const parseJsonBody: (req: Request) => Promise<MaybeBody> = Promise.promisify(jsonBody);

export type HandlerCallback = (err?: Error | string) => void;
export type RequestHandler = (req: Request, res: Response, cb?: HandlerCallback) => void;

Expand Down Expand Up @@ -73,7 +67,7 @@ export class SwaggerRouter {
*/
public controller(factory: ControllerFactory, spec: OpenApiSpec): void {
assert(typeof factory === 'function', 'Controller factory must be a function.');
assert(typeof spec === 'object' && !!spec, 'API speciification must be a non-null object');
assert(typeof spec === 'object' && !!spec, 'API specification must be a non-null object');
if (!spec.paths || !Object.keys(spec.paths).length) {
return;
}
Expand All @@ -92,12 +86,7 @@ export class SwaggerRouter {
}

private _handleRequest(request: Request, response: Response, next: HandlerCallback): void {
// TODO(bajtos) The following parsing can be skipped when the router
// is mounted on an express app
const parsedRequest = request as ParsedRequest;
const parsedUrl = url.parse(parsedRequest.url, true);
parsedRequest.path = parsedUrl.pathname || '/';
parsedRequest.query = parsedUrl.query;
const parsedRequest = parseRequestUrl(request);

debug('Handle request "%s %s"', request.method, parsedRequest.path);

Expand Down Expand Up @@ -134,7 +123,7 @@ export class SwaggerRouter {
}
}

interface ParsedRequest extends Request {
export interface ParsedRequest extends Request {
// see http://expressjs.com/en/4x/api.html#req.path
path: string;
// see http://expressjs.com/en/4x/api.html#req.query
Expand Down Expand Up @@ -185,10 +174,10 @@ class Endpoint {
}

const operationName = this._spec['x-operation-name'];
// tslint:disable-next-line:no-floating-promises
Promise.resolve(this._controllerFactory(request, response, operationName))
.then(controller => {
loadRequestBodyIfNeeded(this._spec, request)
.then(body => buildOperationArguments(this._spec, request, pathParams, body))
return parseOperationArgs(request, this._spec, pathParams)
.then(
args => {
invoke(controller, operationName, args, response, next);
Expand All @@ -197,79 +186,31 @@ class Endpoint {
debug('Cannot parse arguments of operation %s: %s', operationName, err.stack || err);
next(err);
});
},
err => {
debug('Cannot resolve controller instance for operation %s: %s', operationName, err.stack || err);
next(err);
});
}
}

function loadRequestBodyIfNeeded(operationSpec: OperationObject, request: Request): Promise<MaybeBody> {
if (!hasArgumentsFromBody(operationSpec))
return Promise.resolve();

const contentType = request.headers['content-type'];
if (contentType && !/json/.test(contentType)) {
const err = createHttpError(415, `Content-type ${contentType} is not supported.`);
return Promise.reject(err);
}

return parseJsonBody(request).catch((err: HttpError) => {
err.statusCode = 400;
return Promise.reject(err);
});
}

function hasArgumentsFromBody(operationSpec: OperationObject): boolean {
if (!operationSpec.parameters || !operationSpec.parameters.length)
return false;

for (const paramSpec of operationSpec.parameters) {
if ('$ref' in paramSpec) continue;
const source = (paramSpec as ParameterObject).in;
if (source === 'formData' || source === 'body')
return true;
}
return false;
}

function buildOperationArguments(operationSpec: OperationObject, request: ParsedRequest,
pathParams: PathParameterValues, body?: MaybeBody): OperationArgs {
const args: OperationArgs = [];

for (const paramSpec of operationSpec.parameters || []) {
if ('$ref' in paramSpec) {
// TODO(bajtos) implement $ref parameters
throw new Error('$ref parameters are not supported yet.');
}
const spec = paramSpec as ParameterObject;
switch (spec.in) {
case 'query':
args.push(request.query[spec.name]);
break;
case 'path':
args.push(pathParams[spec.name]);
break;
case 'header':
args.push(request.headers[spec.name.toLowerCase()]);
break;
case 'formData':
args.push(body ? body[spec.name] : undefined);
break;
case 'body':
args.push(body);
break;
default:
throw createHttpError(501, 'Parameters with "in: ' + spec.in + '" are not supported yet.');
}
}
return args;
}

interface HttpError extends Error {
export interface HttpError extends Error {
statusCode?: number;
status?: number;
}

function createHttpError(statusCode: number, message: string) {
export function createHttpError(statusCode: number, message: string) {
const err = new Error(message) as HttpError;
err.statusCode = statusCode;
return err;
}

export function parseRequestUrl(request: Request): ParsedRequest {
// TODO(bajtos) The following parsing can be skipped when the router
// is mounted on an express app
const parsedRequest = request as ParsedRequest;
const parsedUrl = url.parse(parsedRequest.url, true);
parsedRequest.path = parsedUrl.pathname || '/';
parsedRequest.query = parsedUrl.query;
return parsedRequest;
}
Original file line number Diff line number Diff line change
Expand Up @@ -351,14 +351,34 @@ context('with an operation echoing a string parameter from query', () => {
});
});

context('error handling', () => {
it('handles errors throws by controller constructor', () => {
const spec = givenOpenApiSpec()
.withOperationReturningString('get', '/hello', 'greet')
.build();

class ThrowingController {
constructor() {
throw new Error('Thrown from constructor.');
}
}

givenControllerClass(ThrowingController, spec);

logErrorsExcept(500);
return client.get('/hello')
.expect(500);
});
});

let router: SwaggerRouter;
function givenRouter() {
router = new SwaggerRouter();
}

// tslint:disable-next-line:no-any
function givenControllerClass(ctor: new (...args: any[]) => Object, spec: OpenApiSpec) {
router.controller((req, res) => new ctor(), spec);
router.controller((req, res) => Promise.resolve().then(() => new ctor()), spec);
}

function logErrorsExcept(ignoreStatusCode: number) {
Expand Down

0 comments on commit 3e25c78

Please sign in to comment.