Skip to content

Commit

Permalink
feat(arrest): Added native support for scope validation, added contex…
Browse files Browse the repository at this point in the history
…tual debug logging and more tes
  • Loading branch information
0xfede committed Mar 20, 2017
1 parent 7e691e6 commit 77d14b6
Show file tree
Hide file tree
Showing 10 changed files with 400 additions and 121 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"debug": "2.6.1",
"eredita": "1.0.1",
"express": "4.14.1",
"jsonpolice": "4.0.1",
"jsonpolice": "4.1.0",
"jsonref": "3.5.0",
"lodash": "4.17.4",
"mongodb": "2.2.24",
Expand All @@ -95,7 +95,7 @@
"path": "node_modules/cz-conventional-changelog"
},
"ghooks": {
"pre-commit": "npm run build && npm run cover && npm run check-coverage"
"_pre-commit": "npm run build && npm run cover && npm run check-coverage"
}
},
"engines": {
Expand Down
99 changes: 62 additions & 37 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import * as semver from 'semver';
import * as jr from 'jsonref';
import { Router, RouterOptions, RequestHandler, Request, Response, NextFunction } from 'express';
import { Eredita } from 'eredita';
import Logger from './debug';
import { Logger, contextLogger } from './debug';
import { RESTError } from './error';
import { SchemaRegistry } from './schema';
import { Swagger } from './swagger';
import { Scopes } from './scopes';
import { Resource } from './resource';

const logger = Logger('arrest');
const __schemas = Symbol();
const __registry = Symbol();
const __resources = Symbol();
Expand Down Expand Up @@ -140,6 +140,17 @@ const __default_schema_tag: Swagger.Tag = {
"x-name-plural": "Schemas"
};

export interface APIRequest extends Request {
scopes: Scopes;
logger: Logger;
}
export interface APIResponse extends Response {
logger: Logger;
}
export interface APIRequestHandler extends RequestHandler {
(req: APIRequest, res: APIResponse, next: NextFunction): any;
}

export class API implements Swagger {
swagger;
info;
Expand Down Expand Up @@ -181,28 +192,59 @@ export class API implements Swagger {
resource.attach(this);
return this;
}
addOperation(path:string, method:string, operation:Swagger.Operation) {

registerSchema(id:string, schema:Swagger.Schema) {
if (!this[__schemas]) {
this[__schemas] = {};
this.registerTag(_.cloneDeep(__default_schema_tag));
this.registerOperation('/schemas/{id}', 'get', _.cloneDeep(__default_schema_operation));
}
this[__schemas][id] = schema;
this.registry.register(`schemas/${id}`, schema);
}
registerOperation(path:string, method:string, operation:Swagger.Operation) {
if (!this.paths) {
this.paths = {};
}
if (!this.paths[path]) {
this.paths[path] = {};
let _path = path;
if (_path.length > 1 && _path[_path.length - 1] === '/') {
_path = _path.substr(0, _path.length - 1);
}
if (!this.paths[_path]) {
this.paths[_path] = {};
}
this.paths[path][method] = operation;
this.paths[_path][method] = operation;
}
addTag(tag:Swagger.Tag) {
registerTag(tag:Swagger.Tag) {
if (!this.tags) {
this.tags = [];
}
this.tags.push(tag);
}
registerOauth2Scope(name:string, description:string): void {
if (this.securityDefinitions) {
_.each(_.filter(this.securityDefinitions, { type: 'oauth2' }), (i:Swagger.SecurityOAuth2) => {
if (!i.scopes) {
i.scopes = {};
}
i.scopes[name] = description;
});
}
}

router(options?: RouterOptions): Promise<Router> {
if (!this[__router]) {
let r = Router(options);
//console.log(this.paths['/tests/a'].post.parameters);
r.use(function(_req: Request, res: Response, next: NextFunction) {
let req: APIRequest = _req as APIRequest;
if (!req.logger) {
req.logger = contextLogger('arrest');
}
next();
});
r.use(this.securityValidator.bind(this));
let originalSwagger:Swagger = _.cloneDeep(this) as Swagger;
r.get('/swagger.json', (req: Request, res: Response, next: NextFunction) => {
r.get('/swagger.json', (req: APIRequest, res: APIResponse, next: NextFunction) => {
let out:any = _.cloneDeep(originalSwagger);
if (!req.headers['host']) {
next(API.newError(400, 'Bad Request', 'Missing Host header in the request'));
Expand All @@ -227,7 +269,7 @@ export class API implements Swagger {
}
});
if (this[__schemas]) {
r.get('/schemas/:id', (req: Request, res: Response, next: NextFunction) => {
r.get('/schemas/:id', (req: APIRequest, res: APIResponse, next: NextFunction) => {
if (this[__schemas][req.params.id]) {
res.json(this[__schemas][req.params.id]);
} else {
Expand Down Expand Up @@ -259,30 +301,13 @@ export class API implements Swagger {
return base;
});
}
securityValidator(security): RequestHandler {
return function(req:Request, res:Response, next:NextFunction) {
logger.warn('using default security validator');
next();
}
}
addOauth2Scope(name:string, description:string): void {
if (this.securityDefinitions) {
_.each(_.filter(this.securityDefinitions, { type: 'oauth2' }), (i:Swagger.SecurityOAuth2) => {
if (!i.scopes) {
i.scopes = {};
}
i.scopes[name] = description;
});
}
}
registerSchema(id:string, schema:Swagger.Schema) {
if (!this[__schemas]) {
this[__schemas] = {};
this.addTag(_.cloneDeep(__default_schema_tag));
this.addOperation('/schemas/{id}', 'get', _.cloneDeep(__default_schema_operation));
securityValidator(req:APIRequest, res:APIResponse, next:NextFunction) {
req.logger.warn('using default security validator');
if (!req.scopes) {
req.logger.warn('scopes not set, setting default to *');
req.scopes = new Scopes('*');
}
this[__schemas][id] = schema;
this.registry.register(`schemas/${id}`, schema);
next();
}

static newError(code: number, message?: string, info?: any, err?: any): RESTError {
Expand All @@ -291,15 +316,15 @@ export class API implements Swagger {
static fireError(code: number, message?: string, info?: any, err?: any): never {
throw API.newError(code, message, info, err);
}
static handleError(err: any, req: Request, res: Response, next?: NextFunction) {
static handleError(err: any, req: APIRequest, res: APIResponse, next?: NextFunction) {
if (err.name === 'RESTError') {
logger.error('REST ERROR', err);
req.logger.error('REST ERROR', err);
RESTError.send(res, err.code, err.message, err.info);
} else if (err.name === 'ValidationError') {
logger.error('DATA ERROR', err);
req.logger.error('DATA ERROR', err);
RESTError.send(res, 400, err.message, err.path);
} else {
logger.error('GENERIC ERROR', err, err.stack);
req.logger.error('GENERIC ERROR', err, err.stack);
RESTError.send(res, 500, 'internal');
}
}
Expand Down
23 changes: 22 additions & 1 deletion src/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,28 @@ export interface Logger {
debug: debug.IDebugger;
}

export default function(label: string): Logger {
let reqId = 0;
export function contextLogger(label: string): Logger {
let id = ++reqId;
let d = function(label:string): debug.IDebugger {
let origDebugger:debug.IDebugger = debug(label);
let wrappedDebugger:debug.IDebugger = <debug.IDebugger>function(formatter: string, ...args: any[]) {
origDebugger(`req-${id} ${formatter}`, ...args);
};
wrappedDebugger.enabled = origDebugger.enabled;
wrappedDebugger.log = origDebugger.log;
wrappedDebugger.namespace = origDebugger.namespace;
return wrappedDebugger;
};
return {
log: d(label + ':log'),
warn: d(label + ':warn'),
error: d(label + ':error'),
debug: d(label + ':debug')
}
}

export function Logger(label: string): Logger {
return {
log: debug(label + ':log'),
warn: debug(label + ':warn'),
Expand Down
25 changes: 9 additions & 16 deletions src/mongo/operation.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import * as url from 'url';
import * as qs from 'querystring';
import * as _ from 'lodash';
import { Request, Response } from 'express';
import * as mongo from 'mongodb';
import Logger from '../debug';
import { Swagger } from '../swagger';
import { API } from '../api';
import { API, APIRequest, APIResponse } from '../api';
import { Resource } from '../resource';
import { Method, Operation } from '../operation';
import { MongoResource } from './resource';
import rql from './rql';

const logger = Logger('arrest');

export interface MongoJob {
req: Request;
res: Response;
req: APIRequest;
res: APIResponse;
coll: mongo.Collection;
query?: any;
doc?: any;
Expand Down Expand Up @@ -67,7 +63,6 @@ export abstract class MongoOperation extends Operation {
[ '' + this.resource.id ]: (idIsObjectId ? new mongo.ObjectID(_id) : _id)
};
} catch (error) {
logger.error('invalid id', error);
API.fireError(404, 'not_found');
}
}
Expand Down Expand Up @@ -113,7 +108,7 @@ export abstract class MongoOperation extends Operation {
return job;
}

handler(req:Request, res:Response) {
handler(req:APIRequest, res:APIResponse) {
this.collection.then((coll: mongo.Collection) => {
return Promise.resolve({ req, res, coll, query: {}, opts: {} } as MongoJob)
.then(job => this.prepareQuery(job))
Expand Down Expand Up @@ -285,8 +280,7 @@ export class ReadMongoOperation extends MongoOperation {
return job;
}
runOperation(job:MongoJob): MongoJob | Promise<MongoJob> {
// TODO stop using find
return job.coll.find(job.query, job.opts).limit(1).next().then(data => {
return job.coll.findOne(job.query, job.opts as mongo.FindOneOptions).then(data => {
if (data) {
job.data = data;
return job;
Expand Down Expand Up @@ -348,10 +342,10 @@ export class CreateMongoOperation extends MongoOperation {
return job;
}, err => {
if (err && err.name === 'MongoError' && err.code === 11000) {
logger.error('duplicate key', err);
job.req.logger.error('duplicate key', err);
API.fireError(400, 'duplicate key');
} else {
logger.error('bad result', err);
job.req.logger.error('bad result', err);
API.fireError(500, 'internal');
}
});
Expand Down Expand Up @@ -421,7 +415,7 @@ export class UpdateMongoOperation extends MongoOperation {
runOperation(job:MongoJob): MongoJob | Promise<MongoJob> {
return job.coll.findOneAndUpdate(job.query, job.doc, job.opts as mongo.FindOneAndReplaceOption).then(result => {
if (!result.ok || !result.value) {
logger.error('update failed', result);
job.req.logger.error('update failed', result);
API.fireError(404, 'not_found');
} else {
job.data = result.value;
Expand Down Expand Up @@ -468,11 +462,10 @@ export class RemoveMongoOperation extends MongoOperation {
return job;
}
runOperation(job:MongoJob): MongoJob | Promise<MongoJob> {
// TODO remove when mongo typings include a proper type
let opts = job.opts as { w?: number | string, wtimmeout?: number, j?: boolean, bypassDocumentValidation?: boolean };
return job.coll.deleteOne(job.query, opts).then(result => {
if (result.deletedCount != 1) {
logger.error('delete failed', result);
job.req.logger.error('delete failed', result);
API.fireError(404, 'not_found');
} else {
return job;
Expand Down
Loading

0 comments on commit 77d14b6

Please sign in to comment.