HTTP

HTTP-Abfragen zu bearbeiten ist mitunter die bekannteste Aufgabe für einen Server. Er wandelt dabei einen Input (HTTP-Request) in einen Output (HTTP-Response) um und führt dabei eine bestimmte Aufgabe aus. Ein Client kann dabei über einem HTTP-Request auf vielfältige Art und Weisen Daten an den Server senden, die korrekt ausgelesen und behandelt werden müssen. So sind neben dem HTTP-Body auch HTTP-Query oder HTTP-Header Werte möglich. Wie Daten konkret verarbeitet werden, hängt vom Server ab. Er ist es, der definiert, wo und wie die Werte vom Client zu senden sind.

Hierbei ist oberste Priorität nicht nur das korrekt auszuführen, was der User erwartet, sondern jeglichen Input aus dem HTTP-Request korrekt umzuwandeln (deserialisieren) und zu validieren.

Die Pipeline, in der ein HTTP-Request auf dem Server durchläuft, kann vielfältig und komplex sein. Viele einfache HTTP-Libraries übergeben für eine bestimmte Route lediglich den HTTP-Request und die HTTP-Response, und erwarten vom Entwickler, den HTTP-Response direkt zu bearbeiten. Eine Middleware-API erlaubt dabei die Pipeline beliebig zu erweitern.

Express Beispiel

const http = express();
http.get('/user/:id', (request, response) => {
    response.send({id: request.params.id, username: 'Peter' );
});

Dies ist für simple Anwendungsfälle sehr gut zugeschnitten, wird aber schnell unübersichtlich, wenn die Anwendung wächst, da alle Ein- und Ausgaben manuell serialisiert beziehungsweise deserialisiert und validiert werden müssen. Auch muss überlegt werden wie Objekte und Services wie zum Beispiel eine Datenbank Abstraktion aus der Anwendung selbst erhaltenen werden können. Es zwingt den Developer eine Architektur selbst obendrauf zu setzen, die diese zwingenden Funktionalitäten abbildet.

Deepkit’s HTTP Library nutzt die Stärke von TypeScript und Dependency Injection. Serialisierung/Deserialisierung und Validierung von jeglichen Werten passieren automatisch anhand der definierten Typen. Auch erlaubt es das Definieren von Routen entweder über eine funktionale API wie in dem Beispiel oben oder über Controller-Klassen, um die unterschiedlichen Bedürfnisse einer Architektur abzudecken.

Es kann dabei entweder mit einem bereits vorhandenen HTTP-Server wie Node’s http Modul oder mit dem Deepkit Framework genutzt werden. Beide API-Varianten haben Zugriff auf den Dependency Injection Container und können so bequem Objekte wie eine Datenbank-Abstraktion und Konfigurationen aus der Anwendung beziehen.

Deepkit Beispiel

import { Positive } from '@deepkit/type';
import { http } from '@deepkit/http';

//Functional API
router.get('/user/:id', (id: number & Positive, database: Database) => {
    //id is guaranteed to be a number and positive.
    //database is injected by the DI Container.
    return database.query(User).filter({id}).findOne();
});

//Controller API
class UserController {
    constructor(private database: Database) {}

    @http.GET('/user/:id')
    user(id: number & Positive) {
        return this.database.query(User).filter({id}).findOne();
    }
}

Installation

Da CLI-Programme in Deepkit auf den Runtime Types basieren, ist es notwendig @deepkit/type bereits korrekt installiert zu haben. Siehe dazu Runtime Type Installation.

Falls dies erfolgreich geschehen ist, kann @deepkit/app installiert werden oder das Deepkit Framework welches die Library bereits unter der Haube benutzt.

npm install @deepkit/http

Zu beachten ist, dass @deepkit/http für die Controller API auf TypeScript-Decorators basiert und dieses Feature entsprechend mit experimentalDecorators aktiviert werden muss, sobald die Controller API verwendet wird.

Datei: tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "es6",
    "moduleResolution": "node",
    "experimentalDecorators": true
  },
  "reflection": true
}

Sobald die Library installiert ist, kann die API davon direkt benutzt werden.

Funktionale API

Die funktionale API basiert auf Funktionen und können über die Router Registry, der über den DI Container der App bezogen werden kann, registriert werden.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { HttpRouterRegistry } from '@deepkit/http';

const app = new App({
    imports: [new FrameworkModule]
});

const router = app.get(HttpRouterRegistry);

router.get('/', () => {
    return "Hello World!";
});

app.run();

Die Router Registry kann auch in Event Listener oder im Bootstrap bezogen werden, sodass basierend auf Modulen, Konfigurationen und sonstigen Providern diverse Routen registriert werden.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';

const app = new App({
    bootstrap: (router: HttpRouterRegistry) => {
        router.get('/', () => {
            return "Hello World!";
        });
    },
    imports: [new FrameworkModule]
});

Sobald Module verwendet werden, können funktionale Routen ebenfalls von Modulen dynamisch bereitgestellt werden.

import { App, createModule } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { HttpRouterRegistry } from '@deepkit/http';

class MyModule extends createModule({}) {
    override process() {
        const router = this.setupGlobalProvider(HttpRouterRegistry);

        router.get('/', () => {
            return "Hello World!";
        });
    }
}

const app = new App({
    imports: [new FrameworkModule, new MyModule]
});

Siehe Framework Modules, um mehr über App Module zu erfahren.

Controller API

Die Controller API basiert auf Klassen und kann dabei über die App-API unter der Option controllers registriert werden.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { http } from '@deepkit/http';

class MyPage {
    @http.GET('/')
    helloWorld() {
        return "Hello World!";
    }
}

new App({
    controllers: [MyPage],
    imports: [new FrameworkModule]
}).run();

Sobald Module verwendet werden, können Controller ebenfalls von Modulen bereitgestellt werden.

import { App, createModule } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { http } from '@deepkit/http';

class MyPage {
    @http.GET('/')
    helloWorld() {
        return "Hello World!";
    }
}

class MyModule extends createModule({
    controllers: [MyPage]
}) {
}

const app = new App({
    imports: [new FrameworkModule, new MyModule]
});

Um dynamisch (je nach Konfigurationoption zum Beispiel) Controller bereitzustellen, kann der process-Hook verwendet werden.

class MyModuleConfiguration {
    debug: boolean = false;
}

class MyModule extends createModule({
    config: MyModuleConfiguration
}) {
    override process() {
        if (this.config.debug) {
            class DebugController {
                @http.GET('/debug/')
                root() {
                    return 'Hello Debugger';
                }
            }
            this.addController(DebugController);
        }
    }
}

Siehe Framework Modules, um mehr über App Module zu erfahren.

HTTP Server

Sofern Deepkit Framework genutzt wird, ist dort ein HTTP Server bereits eingebaut. Die HTTP-Library kann jedoch auch ohne den Einsatz des Deepkit Frameworks mit einem eigenen HTTP-Server genutzt werden.

import { Server } from 'http';
import { HttpRequest, HttpResponse } from '@deepkit/http';

const app = new App({
    controllers: [MyPage],
    imports: [new HttpModule]
});

const httpKernel = app.get(HttpKernel);

new Server(
    { IncomingMessage: HttpRequest, ServerResponse: HttpResponse, },
    ((req, res) => {
        httpKernel.handleRequest(req as HttpRequest, res as HttpResponse);
    })
).listen(8080, () => {
    console.log('listen at 8080');
});

HTTP Client

todo: fetch API, validation, und cast.

Route Names

Routen können einen eindeutigen Namen erhalten, welcher bei einer Weiterleitung referenziert werden kann. Je nach API unterscheidet sich die Art wie ein Name definiert wird.

//functional API
router.get({
    path: '/user/:id',
    name: 'userDetail'
}, (id: number) => {
    return {userId: id};
});

//controller API
class UserController {
    @http.GET('/user/:id').name('userDetail')
    userDetail(id: number) {
        return {userId: id};
    }
}

Von allen Routen mit einem Namen kann die URL durch Router.resolveUrl() angefordert werden.

import { HttpRouter } from '@deepkit/http';
const router = app.get(HttpRouter);
router.resolveUrl('userDetail', {id: 2}); //=> '/user/2'

Dependency Injection

Die Router-Funktionen sowie die Controller-Klassen und Controller-Methoden können beliebige Abhängigkeiten definieren, die durch den Dependency Injection Container aufgelöst werden. So ist es zum Beispiel möglich bequem an eine Datenbank-Abstraktion oder Logger zu kommen.

Wenn zum Beispiel eine Datenbank als Provider zur Verfügung gestellt wurde, kann diese injiziert werden:

class Database {
    //...
}

const app = new App({
    providers: [
        Database,
    ],
});

Funktionaler API:

router.get('/user/:id', async (id: number, database: Database) => {
    return await database.query(User).filter({id}).findOne();
});

Controller API:

class UserController {
    constructor(private database: Database) {}

    @http.GET('/user/:id')
    async userDetail(id: number) {
        return await this.database.query(User).filter({id}).findOne();
    }
}

//alternatively directly in the method
class UserController {
    @http.GET('/user/:id')
    async userDetail(id: number, database: Database) {
        return await database.query(User).filter({id}).findOne();
    }
}

Siehe Dependency Injection für mehr Informationen.

Input

Alle nachfolgenden Input-Variationen funktionen bei der funktionalen wie auch der Controller API gleich. Sie erlauben es, Daten aus einen HTTP-Request typen-sicher und entkoppelt auszulesen. Dies führt nicht nur zu einer deutlichen erhöhten Sicherheit, sondern auch einfacheres Unit-Testen, da streng genommen nicht einmal ein HTTP-Request Objekt existieren muss, um die Route zu testen.

Alle Parameter werden dabei automatisch in den definierten TypeScript-Typen umgewandelt (deserialisiert) und validiert. Dies geschieht über das @deepkit/type Paket und seinen Serialization und Validation Features.

Der Einfachheit halber sind nachfolgend alle Beispiel mit der funktionalen API abgebildet.

Path Parameters

Path Parameter sind Werte, die aus der URL der Route extrahiert werden. Der Typ des Wertes richtet sich nach dem Typen an dem dazugehörigen Parameter der Funktion beziehungsweise Methode. Die Umwandlung geschieht automatisch mit dem Feature Weiche Typenkonvertierung.

router.get('/:text', (text: string) => {
    return 'Hello ' + text;
});
$ curl http://localhost:8080/galaxy
Hello galaxy

Ist ein Path Parameter als ein anderer Typ als String definiert, so wird dieser korrekt umgewandelt.

router.get('/user/:id', (id: number) => {
    return `${id} ${typeof id}`;
});
$ curl http://localhost:8080/user/23
23 number

Es können auch zusätzliche Validierung-Einschränken auf den Typen angewendet werden.

import { Positive } from '@deepkit/type';

router.get('/user/:id', (id: number & Positive) => {
    return `${id} ${typeof id}`;
});

Alle Validierung-Typen aus @deepkit/type können angewendet werden. Hierzu ist mehr in HTTP Validation zu finden.

Die Path Parameter haben standardmäßig bei dem URL-Matching [^/]+ als Regular-Expression gesetzt. Das RegExp dazu kann wie folgt angepasst werden:

import { HttpRegExp } from '@deepkit/http';
import { Positive } from '@deepkit/type';

router.get('/user/:id', (id: HttpRegExp<number & Positive, '[0-9]+'>) => {
    return `${id} ${typeof id}`;
});

Dies ist nur in Ausnahmefällen nötig, da oft die Typen in Kombination mit Validierung-Typen selbst bereits mögliche Werte korrekt einschränken.

Query Parameters

Query Parameter sind Werte aus der URL hinter dem ?-Zeichen und können mit dem Typ HttpQuery<T> ausgelesen werden. Der Name des Parameters entspricht dabei dem Namen des Query-Parameters.

import { HttpQuery } from '@deepkit/http';

router.get('/', (text: HttpQuery<number>) => {
    return `Hello ${text}`;
});
$ curl http://localhost:8080/\?text\=galaxy
Hello galaxy

Auch Query Parameter sind automatisch deserialisiert und validiert.

import { HttpQuery } from '@deepkit/http';
import { MinLength } from '@deepkit/type';

router.get('/', (text: HttpQuery<string> & MinLength<3>) => {
    return 'Hello ' + text;
}
$ curl http://localhost:8080/\?text\=galaxy
Hello galaxy
$ curl http://localhost:8080/\?text\=ga
error

Alle Validierung-Typen aus @deepkit/type können angewendet werden. Hierzu ist mehr in HTTP Validation zu finden.

Warnung: Parameterwerte werden nicht escaped/sanitized. Ihre direkte Rückgabe in einer Zeichenkette in einer Route als HTML öffnet eine Sicherheitslücke (XSS). Stelle sicher, dass niemals externen Eingabe vertraut werden und filtere/sanitize/konvertiere Daten, wo nötig.

Query Model

Bei sehr vielen Query Parametern kann es schnell unübersichtlich werden. Um hier wieder Ordnung hereinzubringen, kann ein Model (Klasse oder Interface) genutzt werden, die alle möglichen Query-Parameter zusammenfasst.

import { HttpQueries } from '@deepkit/http';

class HelloWorldQuery {
    text!: string;
    page: number = 0;
}

router.get('/', (query: HttpQueries<HelloWorldQuery>) {
    return 'Hello ' + query.text + ' at page ' + query.page;
}
$ curl http://localhost:8080/\?text\=galaxy&page=1
Hello galaxy at page 1

Die Properties in dem angegebenen Model können alle TypeScript-Typen und Validierung-Typen beinhalten, die @deepkit/type unterstützt. Sieh dazu das Kapitel Serialization und Validation.

Body

Für HTTP-Methoden, die einen HTTP-Body erlauben, kann auch ein body model festgelegt werden. Der Body-Inhaltstyp von dem HTTP-Request muss entweder application/x-www-form-urlencoded, multipart/form-data oder application/json sein, damit Deepkit dies automatisch in JavaScript Objekte umwandeln kann.

import { HttpBody } from '@deepkit/type';

class HelloWorldBody {
    text!: string;
}

router.post('/', (body: HttpBody<HelloWorldBody>) => {
    return 'Hello ' + body.text;
}

Header

Stream

Manual Validation Handling

Um manuell die Validierung des Body-Models zu übernehmen, kann ein spezieller Typ HttpBodyValidation<T> benutzt werden. Er erlaubt es, auch invalide Body-Daten zu empfangen und ganz spezifisch auf Fehlermeldungen zu reagieren.

import { HttpBodyValidation } from '@deepkit/type';

class HelloWorldBody {
    text!: string;
}

router.post('/', (body: HttpBodyValidation<HelloWorldBody>) => {
    if (!body.valid()) {
        // Houston, we got some errors.
        const textError = body.getErrorMessageForPath('text');
        return 'Text is invalid, please fix it. ' + textError;
    }

    return 'Hello ' + body.text;
})

Sobald valid() den Wert false zurückgibt, können die Werte in dem angegebenen Model in einem fehlerhaften Zustand sein. Das bedeutet, dass die Validierung fehlgeschlagen ist. Wenn HttpBodyValidation nicht verwendet wird und eine fehlerhafte HTTP-Request eingeht, würde die Anfrage direkt abgebrochen werden und der Code in der Funktion nie ausgeführt. Verwende HttpBodyValidation nur dann, wenn zum Beispiel Fehlermeldungen bezüglich des Bodys manuell in derselben Route verwertet werden sollen.

Die Properties in dem angegebenen Model können alle TypeScript-Typen und Validierung-Typen beinhalten, die @deepkit/type unterstützt. Sieh dazu das Kapitel Serialization und Validation.

File Upload

Ein spezieller Property-Typ an dem Body-Model kann genutzt werden, um dem Client zu erlauben, Dateien hochzuladen. Es können beliebig viele UploadedFile verwendet werden.

import { UploadedFile, HttpBody } from '@deepkit/http';
import { readFileSync } from 'fs';

class HelloWordBody {
    file!: UploadedFile;
}

router.post('/', (body: HttpBody<HelloWordBody>) => {
    const content = readFileSync(body.file.path);

    return {
        uploadedFile: body.file
    };
})
$ curl http://localhost:8080/ -X POST -H "Content-Type: multipart/form-data" -F "[email protected]/23931.png"
{
    "uploadedFile": {
        "size":6430,
        "path":"/var/folders/pn/40jxd3dj0fg957gqv_nhz5dw0000gn/T/upload_dd0c7241133326bf6afddc233e34affa",
        "name":"23931.png",
        "type":"image/png",
        "lastModifiedDate":"2021-06-11T19:19:14.775Z"
    }
}

Standardmäßig speichert der Router alle hochgeladenen Dateien in einen Temp-Ordner und entfernt diese, sobald der Code in der Route ausgeführt wurde. Es ist daher notwendig, die Datei in dem angegebenen Pfad in path auszulesen und an einen permanenten Ort zu speichern (lokale Festplatte, Cloud Storage, Datenbank).

Validation

Validation in einem HTTP-Server ist ein zwingend notwendige Funktionalität, da fast immer mit Daten gearbeitet wird, die nicht vertrauenswürdig sind. Um an so mehr Stellen Daten validiert werden, umso stabiler ist der Server. Die Validierung in HTTP-Routen kann bequem über Typen und Validierung-Einschränkungen genutzt werden und wird mit einem hoch-optimierten Validator aus @deepkit/type geprüft, sodass es nicht zu Performanceproblemen diesbezüglich kommen kann. Es ist daher dringend empfehlenswert diese Validierungsfähigkeiten auch zu nutzen. Besser ein Mal zu viel, als ein Mal zu wenig.

Alle Inputs wie Path-Parameter, Query-Parameter, und Body-Parameter werden automatisch auf den angegebenen TypeScript-Typ validiert. Sind zusätzliche Einschränkungen über Typen von @deepkit/type angegeben, werden diese ebenfalls geprüft.

import { HttpQuery, HttpQueries, HttpBody } from '@deepkit/http';
import { MinLength } from '@deepkit/type';

router.get('/:text', (text: string & MinLength<3>) => {
    return 'Hello ' + text;
}

router.get('/', (text: HttpQuery<string> & MinLength<3>) => {
    return 'Hello ' + text;
}

interface MyQuery {
     text: string & MinLength<3>;
}

router.get('/', (query: HttpQueries<MyQuery>) => {
    return 'Hello ' + query.text;
}

router.post('/', (body: HttpBody<MyQuery>) => {
    return 'Hello ' + body.text;
}

Siehe Validation für mehr Informationen dazu.

Output

Eine Route kann verschiedene Datenstrukturen zurückgeben. Einige von ihnen werden auf besondere Weise behandelt, wie z. B. Weiterleitungen und Templates, und andere, wie einfache Objekte, werden einfach als JSON gesendet.

JSON

Per Default werden normale JavaScript-Werte als JSON mit dem Header application/json; charset=utf-8 an den Client zurückgesendet.

router.get('/', () => {
    // will be sent as application/json
    return {hello: 'world'}
});

Ist ein expliziter Return-Typ bei der Funktion oder Methode angegeben, werden entsprechend dieses Typen die Daten in JSON mit dem Deepkit JSON Serializer serialisiert.

interface ResultType {
    hello: string;
}

router.get('/', (): ResultType => {
    // will be sent as application/json and additionalProperty is dropped
    return {hello: 'world', additionalProperty: 'value'};
});

HTML

Um HTML zu senden, gibt es zwei Möglichkeiten. Entweder wird das Objekt HtmlResponse oder Template-Engine mit TSX verwendet.

import { HtmlResponse } from '@deepkit/http';

router.get('/', () => {
    // will be sent as Content-Type: text/html
    return new HtmlResponse('<b>Hello World</b>');
});
router.get('/', () => {
    // will be sent as Content-Type: text/html
    return <b>Hello World</b>;
});

Die Template-Engine Variante mit TSX hat dabei den Vorteil, dass genutzte Variablen automatisch HTML-escaped werden. Siehe dazu Template.

Custom Content

Es ist neben HTML und JSON auch möglich Text- oder Binäre-Daten mit einer bestimmten Content-Type zu senden. Dies geschieht über das Objekt Response

import { Response } from '@deepkit/http';

router.get('/', () => {
    return new Response('<title>Hello World</title>', 'text/xml');
});

HTTP Errors

Es ist durch das Werfen von diversen HTTP-Errors möglich, die Verarbeitung eines HTTP-Requests sofort zu unterbrechen und den entsprechenden HTTP-Status des Errors auszugeben.

import { HttpNotFoundError } from '@deepkit/http';

router.get('/user/:id', async (id: number, database: Database) => {
    const user = await database.query(User).filter({id}).findOneOrUndefined();
    if (!user) throw new HttpNotFoundError('User not found');
    return user;
});

Per default werden alle Errors als JSON dem Client zurückgegeben. Dieses Verhalten kann man beliebig im Event-System unter dem Event httpWorkflow.onControllerError anpassen. Siehe dazu die Sektion HTTP Events.

Error class Status

HttpBadRequestError

400

HttpUnauthorizedError

401

HttpAccessDeniedError

403

HttpNotFoundError

404

HttpMethodNotAllowedError

405

HttpNotAcceptableError

406

HttpTimeoutError

408

HttpConflictError

409

HttpGoneError

410

HttpTooManyRequestsError

429

HttpInternalServerError

500

HttpNotImplementedError

501

Der Error HttpAccessDeniedError stellt hierbei eine besonderheit dar. Sobald er geworfen wird, springt der HTTP Workflow (sieh HTTP Events) nicht zu controllerError sondern zu accessDenied.

Benutzerdefinierte HTTP-Errors können mit createHttpError angelegt und geworfen werden.

export class HttpMyError extends createHttpError(412, 'My Error Message') {
}

Zusätzliche Header

Um den Header einer HTTP-Response zu verändert, kann auf den Objekten Response, JSONResponse, und HTMLResponse zusätzliche Methoden aufgerufen werden.

import { Response } from '@deepkit/http';

router.get('/', () => {
    return new Response('Access Denied', 'text/plain')
        .header('X-Reason', 'unknown')
        .status(403);
});

Redirect

Um eine 301 oder 302 Weiterleitung als Antwort zurückzugeben, kann Redirect.toRoute oder Redirect.toUrl verwendet werden.

import { Redirect } from '@deepkit/http';

router.get({path: '/', name: 'homepage'}, () => {
    return <b>Hello World</b>;
});

router.get({path: '/registration/complete'}, () => {
    return Redirect.toRoute('homepage');
});

Die Methode Redirect.toRoute verwendet hierbei den Namen der Route. Wie ein Routen-Name gesetzt werden kann, ist in der Sektion HTTP Route Name einzusehen. Wenn diese referenzierte Route (Query oder Pfad) Parameter beinhaltet, können diese über das zweite Argument angegeben werden:

router.get({path: '/user/:id', name: 'user_detail'}, (id: number) => {

});

router.post('/user', (user: HttpBody<User>) => {
    //... store user and redirect to its detail page
    return Redirect.toRoute('user_detail', {id: 23});
});

Alternativ kann auf eine URL mit Redirect.toUrl weitergeleitet werden.

router.post('/user', (user: HttpBody<User>) => {
    //... store user and redirect to its detail page
    return Redirect.toUrl('/user/' + 23);
});

Standardmäßig benutzen beide einen 302-Weiterleitung. Dies kann über das Argument statusCode angepasst werden.

Scope

Alle HTTP-Controller und funktionalen Routen werden innerhalb des http Dependency Injection Scope verwaltet. HTTP-Controller werden entsprechend für jeden HTTP-Request neu instantiiert. Das bedeutet auch, dass beide auf Provider, die für den Scope http registriert sind, zugreifen können. So sind zusätzlich HttpRequest und HttpResponse aus @deepkit/http als Abhängigkeit nutzbar. Wenn Deepkit Framework benutzt, ist auch SessionHandler aus @deepkit/framework verfügbar.

import { HttpResponse } from '@deepkit/http';

router.get('/user/:id', (id: number, request: HttpRequest) => {
});

router.get('/', (response: HttpResponse) => {
    response.end('Hello');
});

Es kann durchaus nützlich sein, Provider in den http Scope zu platzieren, um zum Beispiel Services für jeden HTTP-Request neu zu instantiieren. Sobald der HTTP-Request bearbeitet wurde, wird der http scoped DI Container gelöscht und so alle seine Provider Instanzen vom Garbage Collector (GC) aufgeräumt.

Siehe Dependency Injection Scopes, um zu erfahren, wie Provider in den http Scope platziert werden können.

Events

Das HTTP-Modul basiert auf einer Workflow-Engine, die verschiedene Event-Tokens bereitstellt, mit denen sich in den gesamten Prozess der Verarbeitung eines HTTP-Requests eingeklinkt werden kann.

Die Workflow-Engine ist dabei eine endliche State-Machine, die für jeden HTTP-Request eine neu State-Machine Instanz anlegt und dann von Position zu Position springt. Die erste Position ist dabei der start und die letzte die response. In jede Position kann zusätzlicher Code ausgeführt werden.

http workflow

Jedes Event-Token hat seinen eigenen Event-Typen mit zusätzlichen Informationen.

Event-Token Description

httpWorkflow.onRequest

When a new request comes in

httpWorkflow.onRoute

When the route should be resolved from the request

httpWorkflow.onRouteNotFound

When the route is not found

httpWorkflow.onAuth

When authentication happens

httpWorkflow.onResolveParameters

When route parameters are resolved

httpWorkflow.onAccessDenied

When access is denied

httpWorkflow.onController

When the controller action is called

httpWorkflow.onControllerError

When the controller action threw an error

httpWorkflow.onParametersFailed

When route parameters resolving failed

httpWorkflow.onResponse

When the controller action has been called. This is the place where the result is converted to a response.

Da alle HTTP-Events auf der Workflow-Engine basieren, kann deren Verhalten abgeändert werden, indem das angegebene Event benutzt wird und dort mit der event.next() Methode weitergesprungen wird.

Das HTTP-Modul verwendet seine eigenen Event-Listener auf diese Event-Tokens, um die Bearbeitung von HTTP-Requests zu implementieren. Alle diese Event-Listener haben eine Priorität von 100, d.h. wenn Sie auf ein Event hören, wird Ihr Listener standardmäßig zuerst ausgeführt (da die Standardpriorität 0 ist). Fügen Sie eine Priorität von über 100 hinzu, um nach den Event-Listener des HTTP-Modules zu laufen.

Nehmen wir zum Beispiel an, Sie wollen das Ereignis abfangen, bei dem ein Controller aufgerufen wird. Wenn ein bestimmter Controller aufgerufen werden soll, prüfen wir, ob der Benutzer Zugriff darauf hat. Wenn der Benutzer Zugriff hat, fahren wir fort. Aber falls nicht, springen wir zur nächsten Workflow-Position accessDenied. Dort wird dann das Prozedere eines Access-Denied automatisch weiterverarbeitet.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { HtmlResponse, http, httpWorkflow } from '@deepkit/http';
import { eventDispatcher } from '@deepkit/event';

class MyWebsite {
    @http.GET('/')
    open() {
        return 'Welcome';
    }

    @http.GET('/admin').group('secret')
    secret() {
        return 'Welcome to the dark side';
    }
}

class SecretRouteListeners {
    @eventDispatcher.listen(httpWorkflow.onController)
    onController(event: typeof httpWorkflow.onController.event) {
        if (event.route.groups.includes('secret')) {
            //check here for authentication information like cookie session, JWT, etc.

            //this jumps to the 'accessDenied' workflow state,
            // essentially executing all onAccessDenied listeners.

            //since our listener is called before the HTTP kernel one,
            // the standard controller action will never be called.
            //this calls event.next('accessDenied', ...) under the hood
            event.accessDenied();
        }
    }

    /**
     * We change the default accessDenied implementation.
     */
    @eventDispatcher.listen(httpWorkflow.onAccessDenied)
    onAccessDenied(event: typeof httpWorkflow.onAccessDenied.event): void {
        if (event.sent) return;
        if (event.hasNext()) return;

        event.send(new HtmlResponse('No access to this area.', 403));
    }
}

new App({
    controllers: [MyWebsite],
    listeners: [SecretRouteListeners],
    imports: [new FrameworkModule]
}).run();
$ curl http://localhost:8080/
Welcome
$ curl http://localhost:8080/admin
No access to this area

Security

Sessions

Middleware

HTTP middlewares allow you to hook into the request/response cycle as an alternative to HTTP events. Its API allows you to use all middlewares from the Express/Connect framework.

Middleware A middleware can either be a class (which is instantiated by the dependency injection container) or a simple function.

import { HttpMiddleware, httpMiddleware, HttpRequest, HttpResponse } from '@deepkit/http';

class MyMiddleware implements HttpMiddleware {
    async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) {
        response.setHeader('middleware', '1');
        next();
    }
}


function myMiddlewareFunction(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) {
    response.setHeader('middleware', '1');
    next();
}

new App({
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware),
        httpMiddleware.for(myMiddlewareFunction),
    ],
    imports: [new FrameworkModule]
}).run();

Global

By using httpMiddleware.for(MyMiddleware) a middleware is registered for all routes, globally.

import { httpMiddleware } from '@deepkit/http';

new App({
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware)
    ],
    imports: [new FrameworkModule]
}).run();

Per Controller

You can limit middlewares to one or multiple controllers in two ways. Either by using the @http.controller or httpMiddleware.for(T).forControllers(). excludeControllers allow you to exclude controllers.

@http.middleware(MyMiddleware)
class MyFirstController {

}
new App({
    providers: [MyMiddleware],
    controllers: [MainController, UsersCommand],
    middlewares: [
        httpMiddleware.for(MyMiddleware).forControllers(MyFirstController, MySecondController)
    ],
    imports: [new FrameworkModule]
}).run();

Per Route Name

forRouteNames along with its counterpart excludeRouteNames allow you to filter the execution of a middleware per route names.

class MyFirstController {
    @http.GET('/hello').name('firstRoute')
    myAction() {
    }

    @http.GET('/second').name('secondRoute')
    myAction2() {
    }
}
new App({
    controllers: [MainController, UsersCommand],
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware).forRouteNames('firstRoute', 'secondRoute')
    ],
    imports: [new FrameworkModule]
}).run();

Per Action/Route

To execute a middleware only for a certain route, you can either use @http.GET().middleware() or httpMiddleware.for(T).forRoute() where forRoute has multiple options to filter routes.

class MyFirstController {
    @http.GET('/hello').middleware(MyMiddleware)
    myAction() {
    }
}
new App({
    controllers: [MainController, UsersCommand],
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware).forRoutes({
            path: 'api/*'
        })
    ],
    imports: [new FrameworkModule]
}).run();

forRoutes() allows as first argument several way to filter for routes.

{
    path?: string;
    pathRegExp?: RegExp;
    httpMethod?: 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'OPTIONS' | 'TRACE';
    category?: string;
    excludeCategory?: string;
    group?: string;
    excludeGroup?: string;
}

Path Pattern

path supports wildcard *.

httpMiddleware.for(MyMiddleware).forRoutes({
    path: 'api/*'
})

RegExp

httpMiddleware.for(MyMiddleware).forRoutes({
    pathRegExp: /'api/.*'/
})

HTTP Method

Filter all routes by a HTTP method.

httpMiddleware.for(MyMiddleware).forRoutes({
    httpMethod: 'GET'
})

Category

category along with its counterpart excludeCategory allow you to filter per route category.

@http.category('myCategory')
class MyFirstController {

}

class MySecondController {
    @http.GET().category('myCategory')
    myAction() {
    }
}
httpMiddleware.for(MyMiddleware).forRoutes({
    category: 'myCategory'
})

Group

group along with its counterpart excludeGroup allow you to filter per route group.

@http.group('myGroup')
class MyFirstController {

}

class MySecondController {
    @http.GET().group('myGroup')
    myAction() {
    }
}
httpMiddleware.for(MyMiddleware).forRoutes({
    group: 'myGroup'
})

Per Modules

You can limit the execution of a module for a whole module.

httpMiddleware.for(MyMiddleware).forModule(ApiModule)

Per Self Modules

To execute a middleware for all controllers/routes of a module where the middleware was registered use forSelfModules().

const ApiModule new AppModule({
    controllers: [MainController, UsersCommand],
    providers: [MyMiddleware],
    middlewares: [
        //for all controllers registered of the same module
        httpMiddleware.for(MyMiddleware).forSelfModules(),
    ],
});

Timeout

All middleware needs to execute next() sooner or later. If a middleware does not execute next() withing a timeout, a warning is logged and the next middleware executed. To change the default of 4seconds to something else use timeout(milliseconds).

const ApiModule = new AppModule({
    controllers: [MainController, UsersCommand],
    providers: [MyMiddleware],
    middlewares: [
        //for all controllers registered of the same module
        httpMiddleware.for(MyMiddleware).timeout(15_000),
    ],
});

Multiple Rules

To combine multiple filters, you can chain method calls.

const ApiModule = new AppModule({
    controllers: [MyController],
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware).forControllers(MyController).excludeRouteNames('secondRoute')
    ],
});

Express Middleware

Almost all express middlewares are supported. Those who access certain request methods of express are not yet supported.

import * as compression from 'compression';

const ApiModule = new AppModule({
    middlewares: [
        httpMiddleware.for(compress()).forControllers(MyController)
    ],
});

Resolver

Der Router unterstützt eine Möglichkeit zur Auflösung komplexer Parametertypen. Wenn zum Beispiel eine Route wie /user/:id gegeben ist, kann diese id mithilfe eines Resolvers in ein User-Objekt außerhalb der Route aufgelöst werden. Dies führt zu einer weiteren Abkopplung von der HTTP-Abstraktion und des Routen-Codes, und vereinfacht so weiter das Testen und die Modularität.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { http, RouteParameterResolverContext, RouteParameterResolver } from '@deepkit/http';

class UserResolver implements RouteParameterResolver {
    constructor(protected database: Database) {}

    async resolve(context: RouteParameterResolverContext) {
        if (!context.parameters.id) throw new Error('No :id given');
        return await this.database.getUser(parseInt(context.parameters.id, 10));
    }
}

@http.resolveParameter(User, UserResolver)
class MyWebsite {
    @http.GET('/user/:id')
    getUser(user: User) {
        return 'Hello ' + user.username;
    }
}

new App({
    controllers: [MyWebsite],
    providers: [UserDatabase, UserResolver],
    imports: [new FrameworkModule]
})
    .run();

Der Decorator in @http.resolveParameter gibt dabei an, welche Klasse mit dem UserResolver aufgelöst werden soll. Sobald nun die angegebene Klasse User als Parameter in der Funktion beziehungsweise Methode angegeben ist, wird der Resolver genutzt, um diese bereitzustellen.

Ist @http.resolveParameter an der Klasse angegeben, erhalten all Methoden dieser Klasse diesen Resolver. Der Decorator kann auch pro Methode angewendet werden:

class MyWebsite {
    @http.GET('/user/:id').resolveParameter(User, UserResolver)
    getUser(user: User) {
        return 'Hello ' + user.username;
    }
}

Auch kann die funktionale API genutzt werden:

router.add(
    http.GET('/user/:id').resolveParameter(User, UserResolver),
    (user: User) => {
        return 'Hello ' + user.username;
    }
);

Das Objekt User muss hierbei nicht zwingend von einem Parameter abhängen. So könnte er genauso gut von einer Session bzw. einem HTTP-Header abhängen, und nur dann bereitgestellt werden, wenn der Benutzer eingeloggt ist. In RouteParameterResolverContext sind viele Informationen über den HTTP-Request verfügbar, sodass viele Anwendungsfälle abbildbar sind.

Im Prinzip ist es auch möglich, komplexe Parametertypen über den Dependency Injection Container aus dem Scope http bereitstellen zu lassen, da diese ebenfalls in der Routen-Funktion bzw. Methode verfügbar sind. Dies hat jedoch den Nachteil, dass kein asynchrone Funktionsaufrufe verwendet werden können, da der DI container durchweg synchron ist.