【scaffdog】スキャフォールディングを使用してAPIの雛形を生成する


新しい API を追加する際、複数のファイルを作成するケースはよくあると思います。  
MVC パターンであれば、Model、View、Controller 各レイヤーの実装が必要だったり、また、アーキテクチャによってはそれ以上のレイヤーが存在したり。

フレームワークの場合、自動生成機能が搭載されていることもあるかと思いますが、自前でアーキテクチャを組んでいる場合は、開発者による作成・実装が必要になります。

自前アーキテクチャ構成で API を追加する際は、既存 API のソースコードやボイラープレートからの流用、もしくは、開発者独自の実装をするケースが多いでしょうか。
これらのケースにおいては、流用コストや開発者独自の実装をした際の可読性の低下・レビューコストの増加が発生する可能性もあります。

今回はこういった問題を避けるべく、Markdown 駆動で API の雛形を生成できるようにしていきます。

# scaffdog とは

Markdown に仕様を定義すると、その仕様に従ってファイルを生成できる Node.js のツールです。  
以下は[公式](https://scaff.dog/)に記載のコンポーネントの雛形を生成するサンプルです。

````markdown
---
name: "component"
root: "."
output: "."
questions:
  name: "Please enter a component name."
---

# `{{ inputs.name | pascal }}/index.ts`

```typescript
export * from "./{{ inputs.name }}";
```

# `{{ inputs.name | pascal }}/{{ inputs.name | pascal }}.tsx`

```typescript
export type Props = React.PropsWithChildren<{}>;

export const {{ inputs.name | pascal }}: React.FC<Props> = ({ children }) => {
  return (
    <div>{children}</div>
  );
};
```
````

Markdown の仕様に従って、以下のファイルが生成されます。

```tsx:PrettyDog/index.ts
export * from "./PrettyDog";
```

```tsx:PrettyDog/PrettyDog.tsx
export type Props = React.PropsWithChildren<{}>;

export const PrettyDog: React.FC<Props> = ({ children }) => {
  return <div>{children}</div>;
};
```

フロントエンドで用いられるケースが多い印象ですが、Node.js 環境であれば、API でも使用できます。  
今回は API の雛形を生成します。

# セットアップ

```bash
$ yarn add -D scaffdog
```

```bash
$ yarn scaffdog init

? Please enter a document name. boilerplate

Setup of scaffdog 🐶 is complete!

  ✔ .scaffdog/config.js
  ✔ .scaffdog/boilerplate.md

Now you can do scaffold by running $ scaffdog generate.

Please refer to the following documents and customize it.
https://scaff.dog/docs/templates

✨  Done in 345.00s.
```

完了すると、`.scaffdog/config.js` と `.scaffdog/boilerplate.md` が生成されます。

```js:.scaffdog/config.js
export default {
  files: ["*"],
};
```

````markdown:.scaffdog/boilerplate.md
---
name: "boilerplate"
root: "."
output: '\*_/_'
ignore: []
questions:
  value: "Please enter any text."
---

# `{{ inputs.value }}.md`

```markdown
Let's make a document!
See scaffdog documentation for details.
https://scaff.dog/docs/templates
```
````

以下、Markdown の説明です。

- `root`：雛形の出力先のルート
- `output`：`root`を基準にした出力先の候補ディレクトリ  
  例として、以下構成の場合、

  ```bash
    src
    ├── x1
    │   └── y1
    │       └── z1
    ├── x2
    │   └── y1
    └── x3
  ```

  生成時にサジェストから出力先を選択できるようになります。

  ```bash
    ? Please select the output destination directory. (Use arrow keys or type to search)
    ❯ src
      src/x1/y1/z1
      src/x2/y1
      src/x3
  ```

- `ignore`：`output`から除外したいディレクトリ
- `questions`：生成時に入力を受け取れる。生成された雛形内で`{{ inputs.<FIELD_NAME> }}`の形式で参照できる  
  例として、以下の定義をした場合、

  ```markdown
  questions:
  name: 'Please enter any text.'
  ```

  生成時に入力を受け付けることができ、

  ```bash
  ? Please enter any text. hoge
  ```

  生成された雛形内で参照できる

  ```markdown
  `{{ inputs.name }}` // hoge
  ```

  文字列で受け取った値は、Pascal Case や Camel Case に変換できたり、その他の細かい設定も可能です。詳細は[公式](https://scaff.dog/docs/templates/attributes#questions)へ。

- ファイル名：`#`（`<h1>`タグ）の形式で定義する

- ファイルの内容：コードブロックの形式で定義する

以上でセットアップは完了です。

# API の雛形を生成する

セットアップで生成した Markdown を更新し、API の雛形を生成します。

今回は以下の構成を想定し、全てのファイルを生成できるようにします。  
生成される各ファイルには、GET API が機能する最低限の実装がされている状態とします。

```bash
user
├── adapters
│   ├── controllers
│   │   ├── user-controller.ts
│   │   └── user-marshaller.ts
│   └── gateways
│       └── repositories
│           ├── user-mapper.ts
│           └── user-repository.ts
├── domain
│   ├── user-entity.ts
│   ├── user-repository-interface.ts
└── usecases
    ├── user-dto.ts
    ├── user-interface.ts
    └── user-usecase.ts
```

## Markdown の更新

※ 一部、フレームワークやライブラリ関連（主に、Express.js、tsoa、TypeORM）の記述も登場しますが、説明は省略します。

````markdown:.scaffdog/boilerplate.md
---
name: "boilerplate"
root: "src"
output: "."
ignore: []
questions:
  domain: "Please enter domain name."
---

# Variables

- domain_pascal: `{{ inputs.domain | pascal }}`
- domain_camel: `{{ inputs.domain | camel }}`

{{ /* Adapter層 */ }}

# `{{ domain_camel }}/adapters/controllers/{{ domain_camel }}-controller.ts`

```typescript
import { Controller, Get, Route, SuccessResponse, Tags } from "tsoa";
import { inject, provideSingleton } from "../../../middlewares/inversify/util";
import { {{ domain_pascal }}Response } from "./{{ domain_camel }}-marshaller";
import { I{{ domain_pascal }}UseCase } from "../../usecases/{{ domain_camel }}-interface";
import { {{ domain_pascal }}Usecase } from "../../usecases/{{ domain_camel }}-usecase";

@Route("{{ domain_camel }}s")
@Tags("{{ domain_pascal }}")
@provideSingleton({{ domain_pascal }}Controller)
export class {{ domain_pascal }}Controller extends Controller {
  @inject({{ domain_pascal }}Usecase) private {{ domain_camel }}Usecase: I{{ domain_pascal }}UseCase;

  @Get()
  @SuccessResponse("200", "Return {{ domain_camel }}s")
  public async get{{ domain_pascal }}s(): Promise<{{ domain_pascal }}Response[]> {
    const result = await this.{{ domain_camel }}Usecase.get{{ domain_pascal }}s();
    return result.map(({{ domain_camel }}) => {{ domain_pascal }}Response.fromDTO({{ domain_camel }}));
  }
}
```

# `{{ domain_camel }}/adapters/controllers/{{ domain_camel }}-marshaller.ts`

```typescript
import { {{ domain_pascal }}DTO } from "../../usecases/{{ domain_camel }}-dto";

export class {{ domain_pascal }}Params {
  id: string;

  constructor(params: {{ domain_pascal }}Params) {
    this.id = params.id;
  }

  static toDTO(params: {{ domain_pascal }}Params): {{ domain_pascal }}DTO {
    return new {{ domain_pascal }}DTO(params);
  }
}

export class {{ domain_pascal }}Response {
  id: string;

  constructor(id: string;) {
    this.id = id;
  }

  static fromDTO(dto: {{ domain_pascal }}DTO): {{ domain_pascal }}Response {
    return new {{ domain_pascal }}Response(dto.id);
  }
}
```

# `{{ domain_camel }}/adapters/gateways/repositories/{{ domain_camel }}-mapper.ts`

```typescript
import {
  Entity,
  PrimaryGeneratedColumn,
} from "typeorm";
import { {{ domain_pascal }} } from "../../../../domain/{{ domain_camel }}-entity";
import { MaxLength, validate } from "class-validator";

@Entity({ name: "{{ domain_camel }}s" })
export class {{ domain_pascal }}Mapper {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  constructor(id: string) {
    this.id = nidame;
  }

  toEntity(): {{ domain_pascal }} {
    return new {{ domain_pascal }}({
      id: this.id,
    });
  }

  static fromEntity(entity: {{ domain_pascal }}): {{ domain_pascal }}Mapper {
    return new {{ domain_pascal }}Mapper(entity.id);
  }
}
```

# `{{ domain_camel }}/adapters/gateways/repositories/{{ domain_camel }}-repository.ts`

```typescript
import { I{{ domain_pascal }}Repository } from "../../../../domain/{{ domain_camel }}-repository-interface";
import { {{ domain_pascal }} } from "../../../../domain/{{ domain_camel }}-entity";
import { provideSingleton } from "../../../../../middlewares/inversify/util";
import { {{ domain_pascal }}Mapper } from "./{{ domain_camel }}-mapper";
import dataSource from "../../../../../infrastructure/typeorm/connection";

@provideSingleton({{ domain_pascal }}Repository)
export class {{ domain_pascal }}Repository implements I{{ domain_pascal }}Repository {
  private repository = dataSource.getRepository({{ domain_pascal }}Mapper);

  async get(): Promise<{{ domain_pascal }}[]> {
    const result = await this.repository.find();
    return result.map(({{ domain_camel }}) => {{ domain_camel }}.toEntity());
  }
}
```

{{ /* Domain層 */ }}

# `{{ domain_camel }}/domain/{{ domain_camel }}-entity.ts`

```typescript
export class {{ domain_pascal }} {
  private readonly _id: string;

  constructor(id: string;) {
    this._id = id;
  }
}
```

# `{{ domain_camel }}/domain/{{ domain_camel }}-repository-interface.ts`

```typescript
import { {{ domain_pascal }} } from "./{{ domain_camel }}-entity";

export interface I{{ domain_pascal }}Repository {
  get(): Promise<{{ domain_pascal }}[]>;
}
```

{{ /* Usecase層 */ }}

# `{{ domain_camel }}/usecases/{{ domain_camel }}-dto.ts`

```typescript
import { {{ domain_pascal }} } from "../domain/{{ domain_camel }}-entity";

export class {{ domain_pascal }}DTO {
  id: string;

  constructor(id: string) {
    this.id = params.id;
  }

  toEntity(): {{ domain_pascal }} {
    return new {{ domain_pascal }}(this.id);
  }

  static fromEntity(entity: {{ domain_pascal }}): {{ domain_pascal }}DTO {
    return new {{ domain_pascal }}DTO(entity.id);
  }
}
```

# `{{ domain_camel }}/usecases/{{ domain_camel }}-interface.ts`

```typescript
import { {{ domain_pascal }}DTO } from "./{{ domain_camel }}-dto";

export interface I{{ domain_pascal }}UseCase {
  get{{ domain_pascal }}s(): Promise<{{ domain_pascal }}DTO[]>;
}
```

# `{{ domain_camel }}/usecases/{{ domain_camel }}-usecase.ts`

```typescript
import { {{ domain_pascal }}Repository } from "../adapters/gateways/repositories/{{ domain_camel }}-repository";
import { I{{ domain_pascal }}Repository } from "../domain/{{ domain_camel }}-repository-interface";
import { inject, provideSingleton } from "../../middlewares/inversify/util";
import { {{ domain_pascal }}DTO } from "./{{ domain_camel }}-dto";
import { I{{ domain_pascal }}UseCase } from "./{{ domain_camel }}-interface";

@provideSingleton({{ domain_pascal }}Usecase)
export class {{ domain_pascal }}Usecase implements I{{ domain_pascal }}UseCase {
  constructor(@inject({{ domain_pascal }}Repository) private {{ domain_camel }}Repository: I{{ domain_pascal }}Repository) {}

  async get{{ domain_pascal }}s(): Promise<{{ domain_pascal }}DTO[]> {
    const result = await this.{{ domain_camel }}Repository.get();
    return result.map(({{ domain_camel }}) => {{ domain_pascal }}DTO.fromEntity({{ domain_camel }}));
  }
}
```
````

## 雛形の生成

生成準備が完了したので、生成してみます。

```bash
$ yarn scaffdog

? Please select a document. boilerplate
ℹ Output destination directory: "src"
? Please enter domain name. user
```

Markdown に記述した仕様に従ってファイルが生成されます（一部抜粋）

```typescript:user/domain/user-entity.ts
export class User {
  private readonly _id: string;

  constructor(id: string;) {
    this._id = id;
  }
}
```

```typescript:user/usecases/user-usecase.ts
import { UserRepository } from "../adapters/gateways/repositories/user-repository";
import { IUserRepository } from "../domain/user-repository-interface";
import { inject, provideSingleton } from "../../middlewares/inversify/util";
import { UserDTO } from "./user-dto";
import { IUserUseCase } from "./user-interface";

@provideSingleton(UserUsecase)
export class UserUsecase implements IUserUseCase {
  constructor(
    @inject(UserRepository) private userRepository: IUserRepository
  ) {}

  async getUsers(): Promise<UserDTO[]> {
    const result = await this.userRepository.get();
    return result.map((user) => UserDTO.fromEntity(user));
  }
}
```

以上で API の雛形の生成が完了しました。

# まとめ

簡単に API を追加できるようになりました。
既存 API の流用（コピペ）作業と比較しても、こちらの方がより効率的、且つ正確な気がします。

Node.js 環境であれば、フロントエンドやバックエンド、インフラ（CDK など）など、様々なケースで活用できそうなので、試してみたいと思います。

以上です。

# Ref

- [scaffdog](https://scaff.dog/)