[Deno]デコレーターとDIを導入してoakをNestJSっぽく書く

Node.jsでWebフレームワークといえばExpressがデファクトスタンダートですが、近年NestJSというTypeScriptベースでデコレーターやDIを使ったフルスタックなWebフレームワークが猛威を振るっています。
GitHubにおけるスターの数も、Expressに迫っています。

![全画面_2021_12_25_1_41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/b1186323-8a28-e541-8d69-64b7805f240c.png)

本題のDenoに話を移すと、DenoにおけるWebフレームワークは色々と立ち上がり始めている様ですが、[こちらの記事](https://dev.to/craigmorten/what-is-the-best-deno-web-framework-2k69)を参照する限りだとExpressっぽいフレームワークである[oak](https://deno.land/x/oak)のスター数が最も多そうです。

そこでこのoakにデコレーターとDIを導入し、NestJSっぽくAPIサーバーを作ってみようと思います。

# 手順
下記の流れで実装していきます。

1. HTTPリクエストメソッドを指定する`Get`デコレーターを作る
1. パスを指定するための`Controller`デコレーターを作る
1. Routerを設定するための`createRouter`ファンクションを作る
1. DIを導入する
1. tsconfig.jsonを更新する

実装後のイメージは、こんな感じ。

```typescript:./main.ts
import { Application } from "https://deno.land/x/oak/mod.ts";
import { createRouter } from "./core.ts";
import { FooController } from "./foo/foo.controller.ts";
import { FooService } from "./foo/foo.service.ts";

const app = new Application();

export const router = createRouter({
  controllers: [FooController],
  providers: [FooService],
  routePrefix: "v1",
});
app.use(router.routes());

await app.listen({ port: 8000 });
```

```typescript:./foo/foo.controller.ts
import { Controller, Get } from "../core.ts";
import { FooService } from "./foo.service.ts";

@Controller("foo")
export class FooController {
  constructor(private readonly fooService: FooService) {}

  @Get("bar")
  bar() {
    return this.analysisService.bar();
  }
}
```

```typescript:./foo/foo.service.ts
import { Injectable } from "https://deno.land/x/inject/mod.ts";

@Injectable()
export class FooService {
  bar() {
    return { value: "bar", status: "ok" };
  }
}
```

# 実装

### 1. HTTPリクエストメソッドを指定する`Get`デコレーターを作る
oakでrouterを登録する際に対象のHTTPリクエストメソッドを指定できるように、HTTPリクエストメソッドを対象のファンクション名やパスなどと併せて`ActionMetadata`インターフェースに沿ってMetadataに登録します。
Metadataの登録には、[reflect_metadata](https://deno.land/x/reflect_metadata)というモジュールを用います。
ついでにGet以外のHTTPリクエストメソッドもサポートしておきます。

```typescript
import { Reflect } from "https://deno.land/x/reflect_metadata/mod.ts";

type HTTPMethods = "get" | "put" | "patch" | "post" | "delete";
interface ActionMetadata {
  path: string;
  method: HTTPMethods;
  functionName: string;
}
const ACTION_KEY = Symbol("action");

function mappingMethod(method: HTTPMethods) {
  return (path = "") =>
    (target: any, functionName: string, _: PropertyDescriptor) => {
      const meta: ActionMetadata = { path, method, functionName };
      addMetadata(meta, target, ACTION_KEY);
    };
}

function addMetadata<T>(value: T, target: any, key: symbol) {
  const list = Reflect.getMetadata(key, target);
  if (list) {
    list.push(value);
    return;
  }
  Reflect.defineMetadata(key, [value], target);
}

export const Get = mappingMethod("get");
export const Post = mappingMethod("post");
export const Put = mappingMethod("put");
export const Patch = mappingMethod("patch");
export const Delete = mappingMethod("delete");
```

### 2. パスを指定するための`Controller`デコレーターを作る

orkでは下記のように子routerを親routerに紐づけることができるので、`Controller`デコレーターではpathとrouterを設定する初期化処理を実装します。

```typescript:main.ts
import { Application, Router } from "https://deno.land/x/oak/mod.ts";

const router = new Router();

const path = /* 対象のパス */;
const childRouter = /* 対象のRouter */;
router.use(path, childRouter.routes(), childRouter.allowedMethods());

const app = new Application();
app.use(router.routes());

await app.listen({ port: 8000 });
```

初期化処理の中で、先程の登録したHTTPリクエストメソッドのメタデータと合わせてあとでまとめてRouterに登録できるように`route`を定義し、getterで取得できる様にしておきます。
合わせて`path`もgetterで取得できる様にしておきます。

```typescript
interface ActionMetadata {
  path: string;
  method: HTTPMethods;
  functionName: string;
}
const ACTION_KEY = Symbol("action");

export function Controller<T extends { new (...instance: any[]): Object }>(
  path: string
) {
  return (fn: T): any =>
    class extends fn {
      private _path?: string;
      private _route?: Router;

      init(routePrefix?: string) {
        const prefix = routePrefix ? `/${routePrefix}` : "";
        this._path = `${prefix}/${path}`;
        const route = new Router();
        const list: ActionMetadata[] = Reflect.getMetadata(
          ACTION_KEY,
          fn.prototype
        );
        list.forEach((meta) => {
          (route as any)[meta.method](
            `/${meta.path}`,
            (context: RouterContext<string>) => {
              context.response.body = (this as any)[meta.functionName](context);
            }
          );
          console.log(`Mapped: [${meta.method.toUpperCase()}]${this.path}/${meta.path}`);
        });

        this._route = route;
      }

      get path(): string | undefined {
        return this._path;
      }

      get route(): Router | undefined {
        return this._route;
      }
    };
}
```

ちなみにpathには、initメソッドで渡される`routePrefix`でprefixを設定できる様になっています。

### 3.　Routerを設定するための`createRouter`ファンクションを作る

最初の実装イメージにあった、Routerを設定するためのcreateRouterを作成します。
併せてrouterのpath登録時にPrefixを設定できる様にもしておきます。
呼び出し方法は下記のイメージ。

```typescript
export const router = createRouter({
  controllers: [FooController],
  routePrefix: "v1",
});
```

`Controller`デコレータで登録されたコントローラには`init`メソッドと`path`と`route`のgetterが存在しているので、createRouterではそれらを使ってrouterを設定していきます。

```typescript
export interface CreateRouterOption {
  controllers: any[];
  routePrefix?: string;
}

export const createRouter = ({
  controllers,
  providers,
  routePrefix,
}: CreateRouterOption) => {
  const router = new Router();
  controllers.forEach((Controller) => {
    const controller = new Controller();
    controller.init(routePrefix);
    const path = controller.path;
    const route = controller.route;
    router.use(path, route.routes(), route.allowedMethods());
  });
  return router;
};
```

### 4. DIを導入する

[inject](https://deno.land/x/inject)モジュールの`Injectable`デコレーターを用いてDIの対象を登録するので、登録されたMetadataを取得した上で、ControllerにDIします。
先程のcreateRouterは下記に様になります。

```typescript
import { bootstrap } from "https://deno.land/x/inject/mod.ts";

export interface CreateRouterOption {
  controllers: any[];
  providers: any[];
  routePrefix?: string;
}

export const createRouter = ({
  controllers,
  providers,
  routePrefix,
}: CreateRouterOption) => {
  const router = new Router();
  controllers.forEach((Controller) => {
    // const controller = new Controller();　コメントアウト
    Reflect.defineMetadata("design:paramtypes", providers, Controller); // ここを追加
    const controller = bootstrap<any>(Controller); // ここを追加
    controller.init(routePrefix);
    const path = controller.path;
    const route = controller.route;
    router.use(path, route.routes(), route.allowedMethods());
  });
  return router;
};
```

### 5. tsconfig.jsonを更新する
TypeScriptでデコレーターを使うために、tsconfig.jsonに下記の設定を追加します。

```json:tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
```

そのため、コマンド実行時には`-c`オプションでファイルを指定する必要があります。

```bash
$ deno run --allow-net -c tsconfig.json main.ts 
```

# まとめ
これで実装は完了です。
ここまでのコードを`core.ts`としてまとめてimportすると、冒頭の「実装後のイメージ」のように実装できます。

```typescript:./main.ts
import { Application } from "https://deno.land/x/oak/mod.ts";
import { createRouter } from "./core.ts";
import { FooController } from "./foo/foo.controller.ts";
import { FooService } from "./foo/foo.service.ts";

const app = new Application();

export const router = createRouter({
  controllers: [FooController],
  providers: [FooService],
  routePrefix: "v1",
});
app.use(router.routes());

await app.listen({ port: 8000 });
```

```typescript:./foo/foo.controller.ts
import { Controller, Get } from "../core.ts";
import { FooService } from "./foo.service.ts";

@Controller("foo")
export class FooController {
  constructor(private readonly fooService: FooService) {}

  @Get("bar")
  bar() {
    return this.analysisService.bar();
  }
}
```

```typescript:./foo/foo.service.ts
import { Injectable } from "https://deno.land/x/inject/mod.ts";

@Injectable()
export class FooService {
  bar() {
    return { value: "bar", status: "ok" };
  }
}
```

起動すると、routerに登録されたpathがログに出力されます。

```bash
$ deno run --allow-net -c tsconfig.json main.ts 
Check file:///Users/xxx/xxx/main.ts
Mapped: [GET]/v1/foo/bar
```


NestJSっぽくするにはパラメーターを受け取るためのデコレーターやGuardの実装など色々と足りないものがありますが、それっぽい実装になりました。
今後さらに機能を追加して、NestJSっぽくしてみようと思います。

[追記]
機能を少し拡充して[oak_decorators](https://github.com/biga816/oak-decorators)という名前でライブラリを公開しました。
使い方はこちらの記事を参照。
https://qiita.com/biga816/items/11b39a86fd6be6aeb3de