From 91c675676939607ee6f428c5ae789d013ad386a9 Mon Sep 17 00:00:00 2001 From: Romain Lenzotti Date: Sun, 15 Jan 2023 11:02:04 +0100 Subject: [PATCH] docs: add platform adapter documentation --- docs/.vuepress/config.base.js | 4 + docs/docs/create-platform-adapter.md | 524 ++++++++++++++++++ packages/platform/common/package.json | 2 +- .../src/components/PlatformExpress.ts | 36 +- .../platform/platform-express/src/index.ts | 1 + .../src/middlewares/multerMiddleware.ts | 32 ++ .../src/components/PlatformKoa.ts | 4 +- packages/platform/platform-koa/src/index.ts | 2 +- .../multerMiddleware.ts} | 8 +- .../platform/platform-test-sdk/package.json | 2 +- 10 files changed, 576 insertions(+), 39 deletions(-) create mode 100644 docs/docs/create-platform-adapter.md create mode 100644 packages/platform/platform-express/src/middlewares/multerMiddleware.ts rename packages/platform/platform-koa/src/{utils/multer.ts => middlewares/multerMiddleware.ts} (87%) diff --git a/docs/.vuepress/config.base.js b/docs/.vuepress/config.base.js index 3e1b64dfc69..846f4d2b94d 100644 --- a/docs/.vuepress/config.base.js +++ b/docs/.vuepress/config.base.js @@ -172,6 +172,10 @@ module.exports = ({title, description, base = "", url, apiRedirectUrl = "", them { text: "Testing", link: `${base}/docs/testing.html` + }, + { + text: "Create Platform", + link: `${base}/docs/create-platform-adapter.html` } ] }, diff --git a/docs/docs/create-platform-adapter.md b/docs/docs/create-platform-adapter.md new file mode 100644 index 00000000000..86c1499ee87 --- /dev/null +++ b/docs/docs/create-platform-adapter.md @@ -0,0 +1,524 @@ +# Introduction + +Platform API create a routing abstraction. That means, all metadata data collected by +Ts.ED decorators will be stored somewhere and can be consumed to map the information +to the server framework of your choice. + +Ts.ED provide use two packages to collect/consume metadata: + +- `@tsed/schema` that you know well, because you use it to declare models +- `@tsed/platform-router` is used by the `@tsed/common` package and the platforms (express/koa) to consume the famous routing model abstraction. + +For example, the following controller will create a @@PlatformLayer@@ with all required data to create a real router: + +```ts +import {Controller} from "@tsed/common"; + +@Controller("/controller") +class MyController { + @Get("/") + get() {} +} +``` + +To get the layer we can do that: + +```ts +import {Controller, InjectorService} from "@tsed/di"; +import {PlatformHandlerType, PlatformHandlerMetadata, PlatformRouter, PlatformLayer} from "@tsed/platform-router"; + +const injector = new InjectorService(); + +injector.addProvider(MyController); +injector.addProvider(NestedController); + +const appRouter = new PlatformRouter(injector); + +// return the layer that decribe the controller and his handlers +appRouter.getLayers(); // [PlatformLayer] +``` + +Here we just described how we can the layers, but we need to example how we can consume and map the data to a real router. +We'll do that step-by-step by implementing the `Express.js` platform as example. + +## Clone the template + +The first step is to create a new project and cloning the `tsed-platform-adapter-starter-kit` template: + +```sh +mkdir tsed-platform-express-5 +cd tsed-platform-express-5 +git clone https://github.com/tsedio/tsed-platform-adapter-starter-kit . +``` + +## Prepare the template + +Replace all following keywords by his replacement in all files: + +- `platform-adapter-kit-starter`: `platform-express-5`, +- `PlatformKitStarter`: `PlatformExpress5`, +- `kitStarter`: `express5` + +Rename the following files: + +- `PlatformKitStarter.ts`: `PlatformExpress5.ts`, +- `PlatformKitStarter.spec.ts`: `PlatformExpress5.spec.ts`, +- `PlatformKitStarterSettings.spec.ts`: `PlatformKitStarterSettings.spec.ts` + +## Install dependencies + +Depending on your platform, you will need to install some dependencies. For Express.js 5, we have to install the following dependencies: + +```sh +yarn add express@next body-parser@next +yarn add -D @types/express compression cookie-parser express-session cors method-override +``` + +## Implement the Platform adapter + +The template implement some integration tests that validate your Platform adapter compatibility with the Ts.ED platform. +These tests ensure that the platform will work as expected for the developer that use your platform. + +The tests are located in `./test/integrations/platform.integration.spec.ts`. +Also, you have a small Ts.ED app under `./test/app`. We'll update this app for our integration test and fit the Express platform prerequisite. + +To run integration tests, just run this command: + +```sh +yarn test +``` + +Actually, all tests fail (timeout)! it's totally normal, because, we haven't configured the server application. So you Platform adapter isn't able +to listen a port and received a request. + +Our job right now, is to pass all mandatory test. + +### Add typings + +In `src/components/PlatformExpress5.ts` change the following lines: + +```diff ++ import Express from "express"; + +- export type Application = any ++ export type Application = Express.Application +``` + +In `src/interfaces/interfaces.ts` change the following lines: + +```diff ++ import Express from "express"; +import {PlatformContext} from "@tsed/common"; +import {PlatformExpress5Settings} from "./PlatformExpress5Settings"; + +declare global { + namespace TsED { +- export interface Application {} ++ export interface Application extends Express.Application {} + + export interface Configuration { + /** + * Configuration related to the platform application. + */ + express5: PlatformExpress5Settings; + } + +- export interface NextFunction { ++ export interface NextFunction extends Express.NextFunction { + + } + +- export interface Response {} ++ export interface Response extends Express.Response {} + export interface Request extends Express.Request { + id: string; + $ctx: PlatformContext; + } + + export interface StaticsOptions {} + } +} + +``` + +### Replace Raw() by the App framework + +In the template you have a fake Raw application. + +```ts +// to be removed and replaced by the web framework class like (Express, Koa, etc...) +function Raw() { + return () => {}; +} +``` + +```diff +- // to be removed and replaced by the web framework class like (Express, Koa, etc...) +- function Raw() { +- return () => {} +- } + +// ... + +- const app = this.injector.settings.get("express5.app") || Raw(); ++ const app = this.injector.settings.get("express5.app") || Express(); +``` + +At this step, we can run the integration test every time is needed. The server should be able to listen connection and receive incoming +request. + +```sh +yarn test +``` + +### Map layers + +Now your platform is able to listen connection, we need to fix the layer mapping to expose the routes declared by our controllers. + +It's basically a step to map the route path and his http method to the Express application. +Here is the template code: + +```ts +export class PlatformExpress5 implements PlatformAdapter { + mapLayers(layers: PlatformLayer[]) { + const app = this.getPlatformApplication(); + const rawApp: any = app.getApp(); + + layers.forEach((layer) => { + switch (layer.method) { + case "statics": + rawApp.use(layer.path, this.statics(layer.path as string, layer.opts as any)); + return; + } + + if (rawApp?.[layer.method]) { + rawApp[layer.method](...layer.getArgs()); + } else { + this.injector.logger.warn(`[MAPLAYERS] ${layer.method} method not implemented yet.`); + } + }); + } +} +``` + +Express has a simple router that have methods for each Http verb (`POST`, `GET`, etc...): + +```js +const app = express(); + +app.get("/", () => {}); +app.post("/", () => {}); +``` + +So the layer mapping is pretty simple and is assumed here: + +```ts +rawApp[layer.method](...layer.getArgs()); +``` + +The `mapLayers` implementation for Koa is pretty similar: + +```ts +export class PlatformKoa implements PlatformAdapter { + mapLayers(layers: PlatformLayer[]) { + const {settings} = this.injector; + const app = this.getPlatformApplication(); + const options = settings.get("koa.router", {}); + const rawRouter = new KoaRouter(options) as any; + + layers.forEach((layer) => { + switch (layer.method) { + case "statics": + rawRouter.use(layer.path, this.statics(layer.path as string, layer.opts as any)); + break; + + default: + rawRouter[layer.method](...layer.getArgs()); + } + }); + + app.getApp().use(rawRouter.routes()).use(rawRouter.allowedMethods()); + } +} +``` + +### Map handler + +When you create a controller, Ts.ED create a layer (verb/path) and handlers that must be mapped to +the server framework (Express here). + +A Ts.ED handler need only a `$ctx` (PlatformContext) as parameter to work, but in Express a signature handler +can be the followings: + +```ts +// for endpoint +function handler(req, res) {} +// for middleware +function middleware(req, res, next) {} +// for error middleware +function middleware(error, req, res, next) {} +``` + +In koa: + +```ts +// for endpoint and middleware +function handler(ctx, next) {} +``` + +To transform an Express handler to a valid Ts.ED handler we can do that through the `mapHandler` method. + +```ts +export class PlatformExpress5 implements PlatformAdapter { + mapHandler(handler: Function, metadata: PlatformHandlerMetadata) { + switch (metadata.type) { + case PlatformHandlerType.RAW_FN: + case PlatformHandlerType.RAW_ERR_FN: + return handler; + case PlatformHandlerType.ERR_MIDDLEWARE: + return async (error: unknown, req: any, res: any, next: any) => { + return runInContext(req.$ctx, () => { + const {$ctx} = req; + + $ctx.next = next; + $ctx.error = error; + + return handler($ctx); + }); + }; + default: + return (req: any, res: any, next: any) => { + return runInContext(req.$ctx, () => { + req.$ctx.next = next; + handler(req.$ctx); + }); + }; + } + } +} +``` + +The template generate a preconfigured mapping (Express like). But if you have to map handler for Koa, the code should be something like that: + +```ts +export class PlatformKoa implements PlatformAdapter { + mapHandler(handler: Function, metadata: PlatformHandlerMetadata) { + if (metadata.isRawMiddleware()) { + return handler; + } + + return async (koaContext: Koa.Context, next: Koa.Next) => { + const {$ctx} = koaContext.request; + $ctx.next = next; + + await handler($ctx); + }; + } +} +``` + +### Bind context to request + +Ts.ED need to create the `$ctx` instance before all handlers. The `$ctx` is an instance of PlatformContext and wrap the request/response object. + +The principle is to register a middleware to invoke a new context each time a request is handled by the server. +The `useContext` method is here to implement correctly the context middleware depending on the server framework used by your adapter. + +Here is an Express example: + +```ts +export class PlatformExpress5 implements PlatformAdapter { + useContext(): this { + const app = this.getPlatformApplication(); + const invoke = createContext(this.injector); + + this.injector.logger.debug("Mount app context"); + + app.use(async (request: any, response: any, next: any) => { + const $ctx = await invoke({request, response}); + await $ctx.start(); + + $ctx.response.getRes().on("finish", () => $ctx.finish()); + + return runInContext($ctx, next); + }); + + return this; + } +} +``` + +While in Koa the middleware is implemented like that: + +```ts +export class PlatformKoa implements PlatformAdapter { + useContext(): this { + const app = this.getPlatformApplication(); + const invoke = createContext(this.injector); + const platformExceptions = this.injector.get(PlatformExceptions); + + this.injector.logger.debug("Mount app context"); + + app.use(async (koaContext: Context, next: Next) => { + const $ctx = await invoke({ + request: koaContext.request as any, + response: koaContext.response as any, + koaContext + }); + + return runInContext($ctx, async () => { + try { + await $ctx.start(); + await next(); + const status = koaContext.status || 404; + + if (status === 404 && !$ctx.isDone()) { + platformExceptions?.resourceNotFound($ctx); + } + } catch (error) { + platformExceptions?.catch(error, $ctx); + } finally { + await $ctx.finish(); + } + }); + }); + + return this; + } +} +``` + +There is some important point to implement the middleware. We need to call: + +- `invoke` to create a new @@PlatformContext@@ instance. +- `$ctx.start()`, to emit the `$onRequest` event. +- The handler in a `runInContext`. This method allow the `@InjectContext` usage in our Service. +- `$ctx.finish()`, to emit the `$onResponse` event. + +### Required middlewares + +Some extra middlewares are necessary for some of Ts.ED features. But it +depends on the server frameworks. + +The middlewares are followings: + +- BodyParser +- Multipart files +- Statics files + +#### Body parser + +```ts +export class PlatformKitStarter implements PlatformAdapter { + bodyParser(type: "json" | "urlencoded" | "raw" | "text", additionalOptions: any = {}): any {} +} +``` + + + + +```ts +export class PlatformExpress5 implements PlatformAdapter { + bodyParser(type: "json" | "text" | "urlencoded", additionalOptions: any = {}): any { + const opts = this.injector.settings.get(`express.bodyParser.${type}`); + + let parser: any = Express[type]; + let options: OptionsJson & OptionsText & OptionsUrlencoded = {}; + + if (isFunction(opts)) { + parser = opts; + options = {}; + } + + if (type === "urlencoded") { + options.extended = true; + } + + options.verify = (req: IncomingMessage & {rawBody: Buffer}, _res: ServerResponse, buffer: Buffer) => { + const rawBody = this.injector.settings.get(`rawBody`); + + if (rawBody) { + req.rawBody = buffer; + } + + return true; + }; + + return parser({...options, ...additionalOptions}); + } +} +``` + + + + +```ts +export class PlatformKoa implements PlatformAdapter { + bodyParser(type: "json" | "urlencoded" | "raw" | "text", additionalOptions: any = {}): any { + const opts = this.injector.settings.get(`koa.bodyParser`); + let parser: any = koaBodyParser; + + let options: Options = {}; + + if (isFunction(opts)) { + parser = opts; + options = {}; + } + + return parser({...options, ...additionalOptions}); + } +} +``` + + + + +#### Multipart + +Ts.ED use multer to handler file upload request. + +```ts +export class PlatformKitStarter implements PlatformAdapter { + multipart(options: PlatformMulterSettings): PlatformMulter { + return multerMiddleware(options); + } +} +``` + + + + +<<< @/packages/platform/platform-express/src/middlewares/multerMiddleware.ts + + + + +<<< @/packages/platform/platform-koa/src/middlewares/multerMiddleware.ts + + + + +#### Statics files + +Ts.ED doesn't impose a specific middleware for this feature. You are free to implement the correct middleware to serve statics files. + +```ts +export class PlatformKitStarter implements PlatformAdapter { + statics(endpoint: string, options: PlatformStaticsOptions) { + return staticsMiddleware(options); + } +} +``` + + + + +<<< @/packages/platform/platform-express/src/middlewares/staticsMiddleware.ts + + + + +<<< @/packages/platform/platform-koa/src/middlewares/staticsMiddleware.ts + + + diff --git a/packages/platform/common/package.json b/packages/platform/common/package.json index 4cf4ce08ae4..258bd258906 100644 --- a/packages/platform/common/package.json +++ b/packages/platform/common/package.json @@ -105,4 +105,4 @@ "optional": false } } -} \ No newline at end of file +} diff --git a/packages/platform/platform-express/src/components/PlatformExpress.ts b/packages/platform/platform-express/src/components/PlatformExpress.ts index da1a1cdc37a..a2242354eb6 100644 --- a/packages/platform/platform-express/src/components/PlatformExpress.ts +++ b/packages/platform/platform-express/src/components/PlatformExpress.ts @@ -1,7 +1,6 @@ import { createContext, InjectorService, - PlatformProvider, PlatformAdapter, PlatformApplication, PlatformBuilder, @@ -10,6 +9,7 @@ import { PlatformHandlerType, PlatformMulter, PlatformMulterSettings, + PlatformProvider, PlatformStaticsOptions, runInContext } from "@tsed/common"; @@ -19,9 +19,8 @@ import type {PlatformViews} from "@tsed/platform-views"; import {OptionsJson, OptionsText, OptionsUrlencoded} from "body-parser"; import Express from "express"; import {IncomingMessage, ServerResponse} from "http"; -import type multer from "multer"; -import {promisify} from "util"; import {PlatformExpressStaticsOptions} from "../interfaces/PlatformExpressStaticsOptions"; +import {multerMiddleware} from "../middlewares/multerMiddleware"; import {staticsMiddleware} from "../middlewares/staticsMiddleware"; declare module "express" { @@ -55,11 +54,8 @@ export class PlatformExpress implements PlatformAdapter { static readonly NAME = "express"; readonly providers = []; - #multer: typeof multer; - constructor(protected injector: InjectorService) { - import("multer").then(({default: multer}) => (this.#multer = multer)); - } + constructor(protected injector: InjectorService) {} /** * Create new serverless application. In this mode, the component scan are disabled. @@ -87,7 +83,7 @@ export class PlatformExpress implements PlatformAdapter { async beforeLoadRoutes() { const injector = this.injector; - const app = this.injector.get>(PlatformApplication)!; + const app = this.getPlatformApplication()!; // disable x-powered-by header injector.settings.get("env") === Env.PROD && app.getApp().disable("x-powered-by"); @@ -96,7 +92,7 @@ export class PlatformExpress implements PlatformAdapter { } async afterLoadRoutes() { - const app = this.injector.get>(PlatformApplication)!; + const app = this.getPlatformApplication()!; const platformExceptions = this.injector.get(PlatformExceptions)!; // NOT FOUND @@ -181,27 +177,7 @@ export class PlatformExpress implements PlatformAdapter { } multipart(options: PlatformMulterSettings): PlatformMulter { - const m = this.#multer(options); - const makePromise = (multer: any, name: string) => { - // istanbul ignore next - if (!multer[name]) return; - - const fn = multer[name]; - - multer[name] = function apply(...args: any[]) { - const middleware: any = Reflect.apply(fn, this, args); - - return (req: any, res: any) => promisify(middleware)(req, res); - }; - }; - - makePromise(m, "any"); - makePromise(m, "array"); - makePromise(m, "fields"); - makePromise(m, "none"); - makePromise(m, "single"); - - return m; + return multerMiddleware(options); } statics(endpoint: string, options: PlatformStaticsOptions) { diff --git a/packages/platform/platform-express/src/index.ts b/packages/platform/platform-express/src/index.ts index 1421587f2a9..6ccabc72221 100644 --- a/packages/platform/platform-express/src/index.ts +++ b/packages/platform/platform-express/src/index.ts @@ -6,4 +6,5 @@ export * from "./components/PlatformExpress"; export * from "./interfaces/PlatformExpressSettings"; export * from "./interfaces/PlatformExpressStaticsOptions"; export * from "./interfaces/interfaces"; +export * from "./middlewares/multerMiddleware"; export * from "./middlewares/staticsMiddleware"; diff --git a/packages/platform/platform-express/src/middlewares/multerMiddleware.ts b/packages/platform/platform-express/src/middlewares/multerMiddleware.ts new file mode 100644 index 00000000000..21c6b3b243b --- /dev/null +++ b/packages/platform/platform-express/src/middlewares/multerMiddleware.ts @@ -0,0 +1,32 @@ +import {PlatformMulterSettings} from "@tsed/common"; +import type multer from "multer"; +import {promisify} from "util"; + +let multerModule: typeof multer; + +import("multer").then(({default: multer}) => (multerModule = multer)); + +export function multerMiddleware(options: PlatformMulterSettings) { + const m = multerModule(options); + + const makePromise = (multer: any, name: string) => { + // istanbul ignore next + if (!multer[name]) return; + + const fn = multer[name]; + + multer[name] = function apply(...args: any[]) { + const middleware: any = Reflect.apply(fn, this, args); + + return (req: any, res: any) => promisify(middleware)(req, res); + }; + }; + + makePromise(m, "any"); + makePromise(m, "array"); + makePromise(m, "fields"); + makePromise(m, "none"); + makePromise(m, "single"); + + return m; +} diff --git a/packages/platform/platform-koa/src/components/PlatformKoa.ts b/packages/platform/platform-koa/src/components/PlatformKoa.ts index 37bb2c131a4..0f8f56dffc3 100644 --- a/packages/platform/platform-koa/src/components/PlatformKoa.ts +++ b/packages/platform/platform-koa/src/components/PlatformKoa.ts @@ -26,7 +26,7 @@ import {staticsMiddleware} from "../middlewares/staticsMiddleware"; import {PlatformKoaHandler} from "../services/PlatformKoaHandler"; import {PlatformKoaRequest} from "../services/PlatformKoaRequest"; import {PlatformKoaResponse} from "../services/PlatformKoaResponse"; -import {getMulter} from "../utils/multer"; +import {multerMiddleware} from "../middlewares/multerMiddleware"; declare global { namespace TsED { @@ -186,7 +186,7 @@ export class PlatformKoa implements PlatformAdapter { } multipart(options: PlatformMulterSettings): PlatformMulter { - return getMulter(options); + return multerMiddleware(options); } statics(endpoint: string, options: PlatformStaticsOptions) { diff --git a/packages/platform/platform-koa/src/index.ts b/packages/platform/platform-koa/src/index.ts index 2c07a5f2068..9db0be24c35 100644 --- a/packages/platform/platform-koa/src/index.ts +++ b/packages/platform/platform-koa/src/index.ts @@ -11,4 +11,4 @@ export * from "./middlewares/staticsMiddleware"; export * from "./services/PlatformKoaHandler"; export * from "./services/PlatformKoaRequest"; export * from "./services/PlatformKoaResponse"; -export * from "./utils/multer"; +export * from "./middlewares/multerMiddleware"; diff --git a/packages/platform/platform-koa/src/utils/multer.ts b/packages/platform/platform-koa/src/middlewares/multerMiddleware.ts similarity index 87% rename from packages/platform/platform-koa/src/utils/multer.ts rename to packages/platform/platform-koa/src/middlewares/multerMiddleware.ts index 89ae7709f9c..0fd63b39c9c 100644 --- a/packages/platform/platform-koa/src/utils/multer.ts +++ b/packages/platform/platform-koa/src/middlewares/multerMiddleware.ts @@ -1,8 +1,8 @@ import Koa from "koa"; import {promisify} from "util"; -let multer: any; -import("multer").then(({default: m}) => (multer = m)); +let multerModule: any; +import("multer").then(({default: m}) => (multerModule = m)); /** * @ignore @@ -52,8 +52,8 @@ function makePromise(multer: any, name: string) { /** * @ignore */ -export function getMulter(options: any) { - const m = multer(options); +export function multerMiddleware(options: any) { + const m = multerModule(options); makePromise(m, "any"); makePromise(m, "array"); diff --git a/packages/platform/platform-test-sdk/package.json b/packages/platform/platform-test-sdk/package.json index ad3b37c8b6d..efcfaaf9bf4 100644 --- a/packages/platform/platform-test-sdk/package.json +++ b/packages/platform/platform-test-sdk/package.json @@ -50,4 +50,4 @@ "eslint": "^8.12.0" }, "peerDependencies": {} -} \ No newline at end of file +}