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

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

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

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

2022年08月14追記
orkの標準機能で回避できる方法を見つけた。

参考

目標

ミドルウェアで 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 の拡張をするときの学びになったので、今回記事に起こした次第。
参考にしていただければ、ありがたいです。

ではでは。

2022年08月14追記

orkの標準機能で回避できる方法を見つけた。

ork_session のサンプルソースを見ると次のような記述をしている箇所が有る。

1
2
3
4
5
type AppState = {
session: Session;
};

const app = new Application<AppState>();

まさしくこのジェネリクスで拡張部分の記述が可能だった。
このように記述することで、context.state の下にジェネリクスで渡した内容が有るものとして処理される。

先に示したソースは次のように記述できる。

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";

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

type CustomProps = {
customProp: MiddlewareAppends;
}

const app = new Application<CustomProps>();

app.use((context: Context, next: () => Promise<unknown>) => {
context.state.customProp = { a: "middleware append value", b: 1234, c: false }; // <== context.state の下に、customProp が生える
next();
});

app.use((context: Context) => {

console.log(context.state.customProp.a);

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

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

以上で、型チェックは通過できる。
呼び出し側でも、足したプロパティの型チェックをせずに参照することが許可される。
仮に、context.state.customProp = { a: "middleware append value", b: 1234, c: false }; を記述しなかったときは、console.log(context.state.customProp.a); が実行時エラーとなる。

1
2
error: Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'a')
console.log(context.state.customProp.a);

実行時エラーを回避する場合、結局型ガードをすることになる。

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
import { Application, Context } from "https://deno.land/x/oak@v10.6.0/mod.ts";

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;
}

type CustomProps = {
customProp: MiddlewareAppends;
}

const app = new Application<CustomProps>();

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

app.use((context: Context) => {
if(isMiddlewareAppends(context.state.customProp)){ // 実行時にプロパティが設定されていないことを想定して
console.log(context.state.customProp.a);
}

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

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

このネタでLTまでしてしまったので、少し悔いがある。次回LT回でちょっと訂正をしておきたい。