diff --git a/README.md b/README.md index 09e84924..f4f36e4a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Some utilities for the development of express applications with Inversify. ## Installation + You can install `inversify-express-utils` using npm: ``` @@ -27,6 +28,7 @@ Please refer to the [InversifyJS documentation](https://github.com/inversify/Inv ## The Basics ### Step 1: Decorate your controllers + To use a class as a "controller" for your express app, simply add the `@controller` decorator to the class. Similarly, decorate methods of the class to serve as request handlers. The following example will declare a controller that responds to `GET /foo'. @@ -73,6 +75,7 @@ export class FooController implements interfaces.Controller { ``` ### Step 2: Configure container and server + Configure the inversify container in your composition root as usual. Then, pass the container to the InversifyExpressServer constructor. This will allow it to register all controllers and their dependencies from your container and attach them to the express app. @@ -109,9 +112,11 @@ app.listen(3000); ``` ## InversifyExpressServer + A wrapper for an express Application. ### `.setConfig(configFn)` + Optional - exposes the express application object for convenient loading of server-level middleware. ```ts @@ -126,6 +131,7 @@ server.setConfig((app) => { ``` ### `.setErrorConfig(errorConfigFn)` + Optional - like `.setConfig()`, except this function is applied after registering all app middleware and controller routes. ```ts @@ -139,6 +145,7 @@ server.setErrorConfig((app) => { ``` ### `.build()` + Attaches all registered controllers and middleware to the express application. Returns the application instance. ```ts @@ -152,6 +159,7 @@ server ``` ## Using a custom Router + It is possible to pass a custom `Router` instance to `InversifyExpressServer`: ```ts @@ -177,6 +185,7 @@ let server = new InversifyExpressServer(container, null, { rootPath: "/api/v1" } ``` ## Using a custom express application + It is possible to pass a custom `express.Application` instance to `InversifyExpressServer`: ```ts @@ -203,30 +212,177 @@ Registers the decorated controller method as a request handler for a particular Shortcut decorators which are simply wrappers for `@httpMethod`. Right now these include `@httpGet`, `@httpPost`, `@httpPut`, `@httpPatch`, `@httpHead`, `@httpDelete`, and `@All`. For anything more obscure, use `@httpMethod` (Or make a PR :smile:). ### `@request()` + Binds a method parameter to the request object. ### `@response()` + Binds a method parameter to the response object. ### `@requestParam(name?: string)` + Binds a method parameter to request.params object or to a specific parameter if a name is passed. ### `@queryParam(name?: string)` + Binds a method parameter to request.query or to a specific query parameter if a name is passed. ### `@requestBody(name?: string)` + Binds a method parameter to request.body or to a specific body property if a name is passed. If the bodyParser middleware is not used on the express app, this will bind the method parameter to the express request object. ### `@requestHeaders(name?: string)` + Binds a method parameter to the request headers. ### `@cookies()` + Binds a method parameter to the request cookies. ### `@next()` + Binds a method parameter to the next() function. +## HttpContext + +The `HttpContext` property allow us to access the current request, +response and user with ease. `HttpContext` is available as a property +in controllers derived from `BaseHttpController`. + +```ts +import { injectable, inject } from "inversify"; +import { + controller, httpGet, BaseHttpController +} from "inversify-express-utils"; + +@injectable() +@controller("/") +class UserPreferencesController extends BaseHttpController { + + @inject("AuthService") private readonly _authService: AuthService; + + @httpGet("/") + public async get() { + const token = this.httpContext.request.headers["x-auth-token"]; + return await this._authService.getUserPreferences(token); + } +} +``` + +If you are creating a custom controller you will need to inject `HttpContext` manually +using the `@httpContext` decorator: + +```ts +import { injectable, inject } from "inversify"; +import { + controller, httpGet, BaseHttpController, httpContext, interfaces +} from "inversify-express-utils"; + +const authService = inject("AuthService") + +@injectable() +@controller("/") +class UserPreferencesController { + + @httpContext private readonly _httpContext: interfaces.HttpContext; + @authService private readonly _authService: AuthService; + + @httpGet("/") + public async get() { + const token = this.httpContext.request.headers["x-auth-token"]; + return await this._authService.getUserPreferences(token); + } +} +``` + +## AuthProvider + +The `HttpContext` will not have access to the current user if you don't +create a custom `AuthProvider` implementation: + +```ts +const server = new InversifyExpressServer( + container, null, null, null, CustomAuthProvider +); +``` + +We need to implement the `AuthProvider` interface. + +The `AuthProvider` allow us to get an user (`Principal`): + +```ts +import { injectable, inject } from "inversify"; +import {} from "inversify-express-utils"; + +const authService = inject("AuthService"); + +@injectable() +class CustomAuthProvider implements interfaces.AuthProvider { + + @authService private readonly _authService: AuthService; + + public async getUser( + req: express.Request, + res: express.Response, + next: express.NextFunction + ): Promise { + const token = req.headers["x-auth-token"] + const user = await this._authService.getUser(token); + const principal = new Principal(user); + return principal; + } + +} +``` + +We alsoneed to implement the Principal interface. +The `Principal` interface allow us to: + +- Access the details of an user +- Check if it has access to certain resource +- Check if it is authenticated +- Check if it is in an user role + +```ts +class Principal implements interfaces.Principal { + public details: any; + public constrcutor(details: any) { + this.details = details; + } + public isAuthenticated(): Promise { + return Promise.resolve(true); + } + public isResourceOwner(resourceId: any): Promise { + return Promise.resolve(resourceId === 1111); + } + public isInRole(role: string): Promise { + return Promise.resolve(role === "admin"); + } +} +``` + +We can then access the current user (Principal) via the `HttpContext`: + +```ts +@injectable() +@controller("/") +class UserDetailsController extends BaseHttpController { + + @inject("AuthService") private readonly _authService: AuthService; + + @httpGet("/") + public async getUserDetails() { + if (this.httpContext.user.isAuthenticated()) { + return this.httpContext.user.details; + } else { + throw new Error(); + } + } +} +``` + ## Examples + Some examples can be found at the [inversify-express-example](https://github.com/inversify/inversify-express-example) repository. ## License diff --git a/package.json b/package.json index 6840b0fd..6e3621ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inversify-express-utils", - "version": "4.1.0", + "version": "4.2.0", "description": "Some utilities for the development of express applications with Inversify", "main": "lib/index.js", "jsnext:main": "es/index.js", diff --git a/src/base_http_controller.ts b/src/base_http_controller.ts new file mode 100644 index 00000000..b7ddf6f1 --- /dev/null +++ b/src/base_http_controller.ts @@ -0,0 +1,8 @@ +import { httpContext } from "../src/decorators"; +import { interfaces } from "../src/interfaces"; +import { injectable } from "inversify"; + +@injectable() +export class BaseHttpController { + @httpContext protected httpContext: interfaces.HttpContext; +} diff --git a/src/constants.ts b/src/constants.ts index 5b36dd5f..ac595427 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,7 @@ const TYPE = { - Controller: Symbol("Controller") + AuthProvider: Symbol("AuthProvider"), + Controller: Symbol("Controller"), + HttpContext: Symbol("HttpContext") }; const METADATA_KEY = { diff --git a/src/decorators.ts b/src/decorators.ts index b1f90fed..8e5263e3 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -1,6 +1,10 @@ import * as express from "express"; import { interfaces } from "./interfaces"; import { METADATA_KEY, PARAMETER_TYPE } from "./constants"; +import { inject } from "inversify"; +import { TYPE } from "../src/constants"; + +export const httpContext = inject(TYPE.HttpContext); export function controller(path: string, ...middleware: interfaces.Middleware[]) { return function (target: any) { diff --git a/src/index.ts b/src/index.ts index 5b9007d0..b86f340d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import { InversifyExpressServer } from "./server"; import { controller, httpMethod, httpGet, httpPut, httpPost, httpPatch, httpHead, all, httpDelete, request, response, requestParam, queryParam, - requestBody, requestHeaders, cookies, next } from "./decorators"; + requestBody, requestHeaders, cookies, next, httpContext } from "./decorators"; import { TYPE } from "./constants"; import { interfaces } from "./interfaces"; +import { BaseHttpController } from "./base_http_controller"; export { interfaces, @@ -25,5 +26,7 @@ export { requestBody, requestHeaders, cookies, - next + next, + BaseHttpController, + httpContext }; diff --git a/src/interfaces.ts b/src/interfaces.ts index a93c9b03..3be7fd93 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -41,6 +41,29 @@ namespace interfaces { rootPath: string; } + export interface Principal { + details: any; + isAuthenticated(): Promise; + // Allows content-based auth + isResourceOwner(resourceId: any): Promise; + // Allows role-based auth + isInRole(role: string): Promise; + } + + export interface AuthProvider { + getUser( + req: express.Request, + res: express.Response, + next: express.NextFunction + ): Promise; + } + + export interface HttpContext { + request: express.Request; + response: express.Response; + user: Principal; + } + } export { interfaces }; diff --git a/src/server.ts b/src/server.ts index 3512473e..5eb2368e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ export class InversifyExpressServer { private _configFn: interfaces.ConfigFunction; private _errorConfigFn: interfaces.ConfigFunction; private _routingConfig: interfaces.RoutingConfig; + private _AuthProvider: { new(): interfaces.AuthProvider}|undefined; /** * Wrapper for the express server. @@ -22,9 +23,10 @@ export class InversifyExpressServer { */ constructor( container: inversify.interfaces.Container, - customRouter?: express.Router, - routingConfig?: interfaces.RoutingConfig, - customApp?: express.Application + customRouter?: express.Router|null, + routingConfig?: interfaces.RoutingConfig|null, + customApp?: express.Application| null, + authProvider?: { new(): interfaces.AuthProvider} ) { this._container = container; this._router = customRouter || express.Router(); @@ -32,6 +34,11 @@ export class InversifyExpressServer { rootPath: DEFAULT_ROUTING_ROOT_PATH }; this._app = customApp || express(); + this._AuthProvider = authProvider; + if (this._AuthProvider) { + container.bind(TYPE.AuthProvider) + .to(this._AuthProvider); + } } /** @@ -81,6 +88,9 @@ export class InversifyExpressServer { private registerControllers() { + // Fake HttpContext is needed during registration + this._container.bind(TYPE.HttpContext).toConstantValue({} as any); + let controllers: interfaces.Controller[] = this._container.getAll(TYPE.Controller); controllers.forEach((controller: interfaces.Controller) => { @@ -101,6 +111,7 @@ export class InversifyExpressServer { ); if (controllerMetadata && methodMetadata) { + let router: express.Router = express.Router(); let controllerMiddleware = this.resolveMidleware(...controllerMetadata.middleware); @@ -136,19 +147,65 @@ export class InversifyExpressServer { private handlerFactory(controllerName: any, key: string, parameterMetadata: interfaces.ParameterMetadata[]): express.RequestHandler { return (req: express.Request, res: express.Response, next: express.NextFunction) => { + let args = this.extractParameters(req, res, next, parameterMetadata); - let result: any = this._container.getNamed(TYPE.Controller, controllerName)[key](...args); - Promise.resolve(result) - .then((value: any) => { - if (value && !res.headersSent) { - res.send(value); - } - }) - .catch((error: any) => { - next(error); }); + + (async () => { + + // create http context instance we use a childContainer for each + // request so we can be sure that this binding is unique for each + // http request that hits the server + const httpContext = await this._getHttpContext(req, res, next); + let childContainer = this._container.createChild(); + childContainer.bind(TYPE.HttpContext) + .toConstantValue(httpContext); + + // invoke controller's action + let result = childContainer.getNamed(TYPE.Controller, controllerName)[key](...args); + Promise.resolve(result) + .then((value: any) => { + if (value && !res.headersSent) { + res.send(value); + } + }) + .catch((error: any) => next(error)); + })(); + }; } + private async _getHttpContext( + req: express.Request, + res: express.Response, + next: express.NextFunction + ) { + const principal = await this._getCurrentUser(req, res, next); + const httpContext = { + request: req, + response: res, + user: principal + }; + return httpContext; + } + + private async _getCurrentUser( + req: express.Request, + res: express.Response, + next: express.NextFunction + ): Promise { + if (this._AuthProvider !== undefined) { + const authProvider = this._container.get(TYPE.AuthProvider); + return await authProvider.getUser(req, res, next); + } else { + return Promise.resolve({ + details: null, + isAuthenticated: () => Promise.resolve(false), + isInRole: (role: string) => Promise.resolve(false), + isResourceOwner: (resourceId: any) => Promise.resolve(false) + }); + } + } + private extractParameters(req: express.Request, res: express.Response, next: express.NextFunction, params: interfaces.ParameterMetadata[]): any[] { let args = []; @@ -173,12 +230,12 @@ export class InversifyExpressServer { return args; } - private getParam(source: any, paramType: string, name: string) { - let param = source[paramType] || source; + private getParam(source: any, paramType: string|null, name: string) { + let param = (paramType !== null) ? source[paramType] : source; return param[name] || this.checkQueryParam(paramType, param); } - private checkQueryParam(paramType: string, param: any) { + private checkQueryParam(paramType: string|null, param: any) { if (paramType === "query") { return undefined; } else { diff --git a/test/auth_provider.test.ts b/test/auth_provider.test.ts new file mode 100644 index 00000000..f6838d91 --- /dev/null +++ b/test/auth_provider.test.ts @@ -0,0 +1,99 @@ +import { expect } from "chai"; +import * as express from "express"; +import { Container, injectable, inject } from "inversify"; +import * as supertest from "supertest"; +import { + InversifyExpressServer, + TYPE, + controller, + httpGet, + BaseHttpController, + interfaces, + httpContext +} from "../src/index"; + +describe("AuthProvider", () => { + + it("Should be able to access current user via HttpContext", (done) => { + + interface SomeDependency { + name: string; + } + + class Principal implements interfaces.Principal { + public details: any; + public constructor(details: any) { + this.details = details; + } + public isAuthenticated() { + return Promise.resolve(true); + } + public isResourceOwner(resourceId: any) { + return Promise.resolve(resourceId === 1111); + } + public isInRole(role: string) { + return Promise.resolve(role === "admin"); + } + } + + @injectable() + class CustomAuthProvider implements interfaces.AuthProvider { + @inject("SomeDependency") private readonly _someDependency: SomeDependency; + public getUser( + req: express.Request, + res: express.Response, + next: express.NextFunction + ) { + const principal = new Principal({ + email: `${this._someDependency.name}@test.com` + }); + return Promise.resolve(principal); + } + } + + interface SomeDependency { + name: string; + } + + @injectable() + @controller("/") + class TestController extends BaseHttpController { + + @inject("SomeDependency") private readonly _someDependency: SomeDependency; + + @httpGet("/") + public async getTest() { + if (this.httpContext.user !== null) { + const email = this.httpContext.user.details.email; + const name = this._someDependency.name; + const isAuthenticated = await this.httpContext.user.isAuthenticated(); + expect(isAuthenticated).eq(true); + return `${email} & ${name}`; + } + } + } + + const container = new Container(); + + container.bind("SomeDependency") + .toConstantValue({ name: "SomeDependency!" }); + + container.bind(TYPE.Controller) + .to(TestController) + .whenTargetNamed("TestController"); + + const server = new InversifyExpressServer( + container, + null, + null, + null, + CustomAuthProvider + ); + + supertest(server.build()) + .get("/") + .expect(200, `SomeDependency!@test.com & SomeDependency!`, done); + + }); + +}); diff --git a/test/base_http_controller.test.ts b/test/base_http_controller.test.ts new file mode 100644 index 00000000..0b606684 --- /dev/null +++ b/test/base_http_controller.test.ts @@ -0,0 +1,60 @@ +import { expect } from "chai"; +import * as express from "express"; +import { Container, injectable, inject } from "inversify"; +import * as supertest from "supertest"; +import { + InversifyExpressServer, + TYPE, + controller, + httpGet, + BaseHttpController, + interfaces +} from "../src/index"; + +describe("BaseHttpController", () => { + + it("Should contain httpContext instance", (done) => { + + interface SomeDependency { + name: string; + } + + @injectable() + @controller("/") + class TestController extends BaseHttpController { + private readonly _someDependency: SomeDependency; + public constructor( + @inject("SomeDependency") someDependency: SomeDependency + ) { + super(); + this._someDependency = someDependency; + } + @httpGet("/") + public async getTest() { + const headerVal = this.httpContext.request.headers["x-custom"]; + const name = this._someDependency.name; + const isAuthenticated = await this.httpContext.user.isAuthenticated(); + expect(isAuthenticated).eq(false); + return `${headerVal} & ${name}`; + } + } + + const container = new Container(); + + container.bind("SomeDependency") + .toConstantValue({ name: "SomeDependency!" }); + + container.bind(TYPE.Controller) + .to(TestController) + .whenTargetNamed("TestController"); + + const server = new InversifyExpressServer(container); + + supertest(server.build()) + .get("/") + .set("x-custom", "test-header!") + .expect(200, `test-header! & SomeDependency!`, done); + + }); + +}); diff --git a/test/framework.test.ts b/test/framework.test.ts index bdeb0d56..ed5aeb9e 100644 --- a/test/framework.test.ts +++ b/test/framework.test.ts @@ -768,6 +768,7 @@ describe("Integration Tests:", () => { .get("/") .expect(200, "foo", done); }); + }); }); diff --git a/test/http_context.test.ts b/test/http_context.test.ts new file mode 100644 index 00000000..08c8eced --- /dev/null +++ b/test/http_context.test.ts @@ -0,0 +1,58 @@ +import { expect } from "chai"; +import * as express from "express"; +import { Container, injectable, inject } from "inversify"; +import * as supertest from "supertest"; +import { + InversifyExpressServer, + TYPE, + controller, + httpGet, + BaseHttpController, + interfaces, + httpContext +} from "../src/index"; + +describe("HttpContex", () => { + + it("Should be able to httpContext manually with the @httpContext decorator", (done) => { + + interface SomeDependency { + name: string; + } + + @injectable() + @controller("/") + class TestController { + + @httpContext private readonly _httpContext: interfaces.HttpContext; + @inject("SomeDependency") private readonly _someDependency: SomeDependency; + + @httpGet("/") + public async getTest() { + const headerVal = this._httpContext.request.headers["x-custom"]; + const name = this._someDependency.name; + const isAuthenticated = await this._httpContext.user.isAuthenticated(); + expect(isAuthenticated).eq(false); + return `${headerVal} & ${name}`; + } + } + + const container = new Container(); + + container.bind("SomeDependency") + .toConstantValue({ name: "SomeDependency!" }); + + container.bind(TYPE.Controller) + .to(TestController) + .whenTargetNamed("TestController"); + + const server = new InversifyExpressServer(container); + + supertest(server.build()) + .get("/") + .set("x-custom", "test-header!") + .expect(200, `test-header! & SomeDependency!`, done); + + }); + +}); diff --git a/test/server.test.ts b/test/server.test.ts index fe2f9bae..817af8b8 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -74,12 +74,9 @@ describe("Unit Test: InversifyExpressServer", () => { it("Should allow to provide a custom express application", () => { let container = new Container(); - let app = express(); - let serverWithDefaultApp = new InversifyExpressServer(container); let serverWithCustomApp = new InversifyExpressServer(container, null, null, app); - expect((serverWithCustomApp as any)._app).to.eq(app); expect((serverWithDefaultApp as any)._app).to.not.eql((serverWithCustomApp as any)._app); }); diff --git a/tsconfig.json b/tsconfig.json index 326ae4ea..53f1888c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "noImplicitAny": true, "preserveConstEnums": true, "suppressImplicitAnyIndexErrors": true, + "strictNullChecks": true, "outDir": "lib" } } diff --git a/tslint.json b/tslint.json index 3eea015c..7173f7c7 100644 --- a/tslint.json +++ b/tslint.json @@ -34,7 +34,6 @@ "no-switch-case-fall-through": false, "no-trailing-whitespace": true, "no-unused-expression": true, - "no-use-before-declare": true, "no-var-keyword": true, "object-literal-sort-keys": true, "one-line": [true,