oak のミドルウェアを書いていくときの技らしきもの

Deno の Web アプリケーションを作ろうとして最初にお世話になるモジュールはstd/httpoak ではないかと思う。

特に oak は、ミドルウェアフレームワークだと説明されるくらいなので、ミドルウェアを上手く使えると都合がいい。
oak 向けの拡張モジュールの多くも、ミドルウェアとして公開されているように思う。

ミドルウェアで機能拡張をしようとしたときのうまい手を見つけたのでメモしておきたい。
(知ってる人からしたら当たり前かもしれない。)

参考

目標

ミドルウェアで oak を機能拡張し、Context に標準ではない情報を追加する。

実装

一旦素直なサーバー実装

server.ts
1
2
3
4
5
6
7
8
9
import { Application, Context } from "https://deno.land/x/oak/mod.ts";

const app = new Application();

app.use((context: Context) => {
context.response.body = "Hello Deno";
});

await app.listen({ port: 8080 });

この実装シンプルなサーバー実装でレスポンスを返す処理の引数になる context に標準ではないプロパティを足したい。

無理やりやる 1

server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Application, Context } from "https://deno.land/x/oak@v10.6.0/mod.ts";

const app = new Application();

app.use((context: Context, next: () => Promise<unknown>) => {
context.customProp = "middleware append value";
next();
});

app.use((context: Context) => {
console.log(context.customProp);
context.response.body = "Hello Deno";
});

await app.listen({ port: 8080 });

このようにしたうえで、--no-check を付けると実行できる。

付けないと次のように型チェックでエラーになる。

1
2
3
4
5
6
7
8
9
10
11
error: TS2339 [ERROR]: Property 'customProp' does not exist on type 'Context<State, Record<string, any>>'.
context.customProp = "middleware append value"
~~~~~~~~~~
at file:///usr/src/app/server.ts:16:13

TS2339 [ERROR]: Property 'customProp' does not exist on type 'Context<State, Record<string, any>>'.
console.log(context.customProp)
~~~~~~~~~~
at file:///usr/src/app/server.ts:21:23

Found 2 errors.

無理やりやる 2

確かに context に customProp がないのだから、エラーになる。
ならば付ければいい。俗にいう「鳴かぬなら鳴かせてみよう」感の有る対処?。

server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { Application, Context } from "https://deno.land/x/oak@v10.6.0/mod.ts";

const app = new Application();

interface CustomProps {
customProp: string;
}

type CustomContext = Context & CustomProps;

app.use((context: CustomContext, next: () => Promise<unknown>) => {
context.customProp = "middleware append value";
next();
});

app.use((context: CustomContext) => {
console.log(context.customProp);
context.response.body = "Hello Deno";
});

await app.listen({ port: 8080 });

この方法で型チェックを行うと次のようにエラーになる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
error: TS2345 [ERROR]: Argument of type '(context: CustomContext, next: () => Promise<unknown>) => void' is not assignable to parameter of type 'Middleware<State, Context<State, Record<string, any>>>'.
Types of parameters 'context' and 'context' are incompatible.
Type 'Context<State, Record<string, any>>' is not assignable to type 'CustomContext'.
Property 'customProp' is missing in type 'Context<State, Record<string, any>>' but required in type 'CustomProps'.
app.use((context: CustomContext, next:()=> Promise<unknown> ) => {
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
at file:///usr/src/app/server.ts:22:9

'customProp' is declared here.
customProp: string
~~~~~~~~~~
at file:///usr/src/app/server.ts:17:3

TS2345 [ERROR]: Argument of type '(context: CustomContext) => void' is not assignable to parameter of type 'Middleware<State, Context<State, Record<string, any>>>'.
Types of parameters 'context' and 'context' are incompatible.
Type 'Context<State, Record<string, any>>' is not assignable to type 'CustomContext'.
app.use((context: CustomContext) => {
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
at file:///usr/src/app/server.ts:27:9

Found 2 errors.

要約すると、上記実装の app.use が要求する型定義を満たしていません。
app.use は、context.customProp に対応していません、知りません。ということ。

型チェックを満たせる対応

server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { Application, Context } from "https://deno.land/x/oak@v10.6.0/mod.ts";

const app = new Application();

interface CustomProps {
customProp?: string; // <= ? を使ってcustomPropを省略可能にする
}

type CustomContext = Context & CustomProps;

app.use((context: CustomContext, next: () => Promise<unknown>) => {
context.customProp = "middleware append value";
next();
});

app.use((context: CustomContext) => {
console.log(context.customProp);
context.response.body = "Hello Deno";
});

await app.listen({ port: 8080 });

これで型チェックにパスできる。

もっと複雑な情報を追加するには

server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Application, Context } from "https://deno.land/x/oak@v10.6.0/mod.ts";

const app = new Application();

interface MiddlewareAppends {
a: string;
b: number;
c: boolean;
}

interface CustomProps {
customProp?: MiddlewareAppends; // <= 省略可能なプロパティを少し複雑にする
}

type CustomContext = Context & CustomProps;

app.use((context: CustomContext, next: () => Promise<unknown>) => {
context.customProp.a = "middleware append value";
next();
});

app.use((context: CustomContext) => {
console.log(context.customProp.a);
context.response.body = "Hello Deno";
});

await app.listen({ port: 8080 });

これを型チェックを行うと、次のようにエラーになる。

1
2
3
4
5
6
7
8
9
10
11
error: TS2532 [ERROR]: Object is possibly 'undefined'.
context.customProp.a = "middleware append value";
~~~~~~~~~~~~~~~~~~
at file:///usr/src/app/server.ts:28:3

TS2532 [ERROR]: Object is possibly 'undefined'.
console.log(context.customProp.a);
~~~~~~~~~~~~~~~~~~
at file:///usr/src/app/server.ts:33:15

Found 2 errors.

こうなってしまう。
.customProp は、undefined でも良いが、その中のプロパティがあるときはこのままではまずい。
ユーザー型ガードを書いて、次のように対応できた。

server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { Application, Context } from "https://deno.land/x/oak@v10.6.0/mod.ts";

const app = new Application();

interface MiddlewareAppends {
a: string;
b: number;
c: boolean;
}

function isMiddlewareAppends(rawArg: unknown): rawArg is MiddlewareAppends {
if (!rawArg) return false;

const arg = rawArg as { [key: string]: unknown };

if (!arg.a) return false;
if (typeof arg.a !== "string") return false;

if (!arg.b) return false;
if (typeof arg.b !== "number") return false;

if (arg.c === null || arg.c === undefined ) return false;
if (typeof arg.c !== "boolean") return false;

return true;
}

interface CustomProps {
customProp?: MiddlewareAppends;
}

type CustomContext = Context & CustomProps;

app.use((context: CustomContext, next: () => Promise<unknown>) => {
context.customProp = { a: "middleware append value", b: 1234, c: false };
next();
});

app.use((context: CustomContext) => {
if (isMiddlewareAppends(context.customProp)) {
console.log(context.customProp.a);
}

context.response.body = "Hello Deno";
});

await app.listen({ port: 8080 });

これで、型チェックをパスできる。


ということで、こういった形でミドルウェアで context の内容を拡張して情報の引き渡しができる。
ミドルウェア認証情報を付加してメインの処理で使いまわす oak のサンプル実装を作って公開した。

実は、SQLite でお安く API サーバーを建てたいというのが念頭に有って作ったら、ミドルウェアで oak の拡張をするときの学びになったので、今回記事に起こした次第。
参考にしていただければ、ありがたいです。

ではでは。