Skip to content

Commit

Permalink
feat: add MiddlewareObject (#589)
Browse files Browse the repository at this point in the history
Co-authored-by: Kitson Kelly <me@kitsonkelly.com>
  • Loading branch information
Symbitic and kitsonk committed Apr 25, 2023
1 parent 5683813 commit b875eec
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 18 deletions.
19 changes: 14 additions & 5 deletions application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { Context } from "./context.ts";
import { KeyStack, Status, STATUS_TEXT } from "./deps.ts";
import { HttpServer } from "./http_server_native.ts";
import { NativeRequest } from "./http_server_native_request.ts";
import { compose, Middleware } from "./middleware.ts";
import {
compose,
isMiddlewareObject,
type MiddlewareOrMiddlewareObject,
} from "./middleware.ts";
import { cloneState } from "./structured_clone.ts";
import {
Key,
Expand Down Expand Up @@ -303,7 +307,7 @@ export class Application<AS extends State = Record<string, any>>
>;
#contextState: "clone" | "prototype" | "alias" | "empty";
#keys?: KeyStack;
#middleware: Middleware<State, Context<State, AS>>[] = [];
#middleware: MiddlewareOrMiddlewareObject<State, Context<State, AS>>[] = [];
#serverConstructor: ServerConstructor<ServerRequest>;

/** A set of keys, or an instance of `KeyStack` which will be used to sign
Expand Down Expand Up @@ -571,6 +575,11 @@ export class Application<AS extends State = Record<string, any>>
if (!this.#middleware.length) {
throw new TypeError("There is no middleware to process requests.");
}
for (const middleware of this.#middleware) {
if (isMiddlewareObject(middleware) && middleware.init) {
await middleware.init();
}
}
if (typeof options === "string") {
const match = ADDR_REGEXP.exec(options);
if (!match) {
Expand Down Expand Up @@ -651,11 +660,11 @@ export class Application<AS extends State = Record<string, any>>
* ```
*/
use<S extends State = AS>(
middleware: Middleware<S, Context<S, AS>>,
...middlewares: Middleware<S, Context<S, AS>>[]
middleware: MiddlewareOrMiddlewareObject<S, Context<S, AS>>,
...middlewares: MiddlewareOrMiddlewareObject<S, Context<S, AS>>[]
): Application<S extends AS ? S : (S & AS)>;
use<S extends State = AS>(
...middleware: Middleware<S, Context<S, AS>>[]
...middleware: MiddlewareOrMiddlewareObject<S, Context<S, AS>>[]
): Application<S extends AS ? S : (S & AS)> {
this.#middleware.push(...middleware);
this.#composedMiddleware = undefined;
Expand Down
88 changes: 88 additions & 0 deletions examples/countingServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* This is an example of how to use an object as a middleware.
* `MiddlewareObject` can be ideal for when a middleware needs to encapsulate
* large amounts of logic or its own state. */

import {
bold,
cyan,
green,
yellow,
} from "https://deno.land/std@0.183.0/fmt/colors.ts";

import {
Application,
composeMiddleware,
type Context,
type MiddlewareObject,
type Next,
} from "../mod.ts";

const app = new Application();

class CountingMiddleware implements MiddlewareObject {
#id = 0;
#counter = 0;

init() {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
this.#id = array[0];
}

handleRequest(ctx: Context, next: Next) {
ctx.response.headers.set("X-Response-Count", String(this.#counter++));
ctx.response.headers.set("X-Response-Counter-ID", String(this.#id));
return next();
}
}

class LoggerMiddleware implements MiddlewareObject {
#composedMiddleware: (context: Context, next: Next) => Promise<unknown>;

constructor() {
this.#composedMiddleware = composeMiddleware([
this.#handleLogger,
this.#handleResponseTime,
]);
}

async #handleLogger(ctx: Context, next: Next) {
await next();
const rt = ctx.response.headers.get("X-Response-Time");
console.log(
`${green(ctx.request.method)} ${cyan(ctx.request.url.pathname)} - ${
bold(
String(rt),
)
}`,
);
}

async #handleResponseTime(ctx: Context, next: Next) {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.response.headers.set("X-Response-Time", `${ms}ms`);
}

handleRequest(ctx: Context, next: Next) {
return this.#composedMiddleware(ctx, next);
}
}

app.use(new CountingMiddleware());
app.use(new LoggerMiddleware());

app.use((ctx) => {
ctx.response.body = "Hello World!";
});

app.addEventListener("listen", ({ hostname, port, serverType }) => {
console.log(
bold("Start listening on ") + yellow(`${hostname}:${port}`),
);
console.log(bold(" using HTTP server: " + yellow(serverType)));
});

await app.listen({ hostname: "127.0.0.1", port: 8000 });
console.log(bold("Finished."));
51 changes: 45 additions & 6 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,57 @@
import type { State } from "./application.ts";
import type { Context } from "./context.ts";

/** Middleware are functions which are chained together to deal with requests. */
/** A function for chaining middleware. */
export type Next = () => Promise<unknown>;

/** Middleware are functions which are chained together to deal with
* requests. */
export interface Middleware<
S extends State = Record<string, any>,
T extends Context = Context<S>,
> {
(context: T, next: () => Promise<unknown>): Promise<unknown> | unknown;
(context: T, next: Next): Promise<unknown> | unknown;
}

/** Middleware objects allow encapsulation of middleware along with the ability
* to initialize the middleware upon listen. */
export interface MiddlewareObject<
S extends State = Record<string, any>,
T extends Context<S> = Context<S>,
> {
/** Optional function for delayed initialization which will be called when
* the application starts listening. */
init?: () => Promise<unknown> | unknown;
/** The method to be called to handle the request. */
handleRequest(context: T, next: Next): Promise<unknown> | unknown;
}

/** Type that represents {@linkcode Middleware} or
* {@linkcode MiddlewareObject}. */
export type MiddlewareOrMiddlewareObject<
S extends State = Record<string, any>,
T extends Context = Context<S>,
> = Middleware<S, T> | MiddlewareObject<S, T>;

/** A type guard that returns true if the value is
* {@linkcode MiddlewareObject}. */
export function isMiddlewareObject<
S extends State = Record<string, any>,
T extends Context = Context<S>,
>(value: MiddlewareOrMiddlewareObject<S, T>): value is MiddlewareObject<S, T> {
return value && typeof value === "object" && "handleRequest" in value;
}

/** Compose multiple middleware functions into a single middleware function. */
export function compose<
S extends State = Record<string, any>,
T extends Context = Context<S>,
>(
middleware: Middleware<S, T>[],
): (context: T, next?: () => Promise<unknown>) => Promise<unknown> {
middleware: MiddlewareOrMiddlewareObject<S, T>[],
): (context: T, next?: Next) => Promise<unknown> {
return function composedMiddleware(
context: T,
next?: () => Promise<unknown>,
next?: Next,
): Promise<unknown> {
let index = -1;

Expand All @@ -31,7 +64,13 @@ export function compose<
throw new Error("next() called multiple times.");
}
index = i;
let fn: Middleware<S, T> | undefined = middleware[i];
let m: MiddlewareOrMiddlewareObject<S, T> | undefined = middleware[i];
let fn: Middleware<S, T> | undefined;
if (typeof m === "function") {
fn = m;
} else if (m && typeof m.handleRequest === "function") {
fn = (m as MiddlewareObject).handleRequest.bind(m);
}
if (i === middleware.length) {
fn = next;
}
Expand Down
60 changes: 54 additions & 6 deletions middleware_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
import { assert, assertEquals, assertStrictEquals } from "./test_deps.ts";
import { errors } from "./deps.ts";
import { createMockContext } from "./testing.ts";
import { compose, Middleware } from "./middleware.ts";
import {
compose,
isMiddlewareObject,
type Middleware,
type MiddlewareObject,
type Next,
} from "./middleware.ts";
import { Context } from "./context.ts";

const { test } = Deno;

test({
Deno.test({
name: "test compose()",
async fn() {
const callStack: number[] = [];
Expand All @@ -31,7 +36,50 @@ test({
},
});

test({
Deno.test({
name: "isMiddlewareObject()",
async fn() {
class MockMiddlewareObject implements MiddlewareObject {
handleRequest(
_context: Context<Record<string, any>, Record<string, any>>,
_next: Next,
): unknown {
return;
}
}

assert(isMiddlewareObject(new MockMiddlewareObject()));
assert(isMiddlewareObject({ handleRequest() {} }));
assert(!isMiddlewareObject(function () {}));
},
});

Deno.test({
name: "middleware objects are composed correctly",
async fn() {
const callStack: number[] = [];
const mockContext = createMockContext();

class MockMiddlewareObject implements MiddlewareObject {
#counter = 0;

async handleRequest(_context: any, next: Next) {
assertEquals(typeof next, "function");
callStack.push(this.#counter++);
await next();
}
}

const mwo = new MockMiddlewareObject();
const fn = compose([mwo]);

await fn(mockContext);
await fn(mockContext);
assertEquals(callStack, [0, 1]);
},
});

Deno.test({
name: "next() is catchable",
async fn() {
let caught: any;
Expand All @@ -51,7 +99,7 @@ test({
},
});

test({
Deno.test({
name: "composed middleware accepts next middleware",
async fn() {
const callStack: number[] = [];
Expand Down
7 changes: 6 additions & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ export { type NativeRequest } from "./http_server_native_request.ts";
export { proxy } from "./middleware/proxy.ts";
export type { ProxyOptions } from "./middleware/proxy.ts";
export { compose as composeMiddleware } from "./middleware.ts";
export type { Middleware } from "./middleware.ts";
export type {
Middleware,
MiddlewareObject,
MiddlewareOrMiddlewareObject,
Next,
} from "./middleware.ts";
export { FormDataReader } from "./multipart.ts";
export type {
FormDataBody,
Expand Down

0 comments on commit b875eec

Please sign in to comment.