新しい API を追加する際、複数のファイルを作成するケースはよくあると思います。
MVC パターンであれば、Model、View、Controller 各レイヤーの実装が必要だったり、また、アーキテクチャによってはそれ以上のレイヤーが存在したり。
フレームワークの場合、自動生成機能が搭載されていることもあるかと思いますが、自前でアーキテクチャを組んでいる場合は、開発者による作成・実装が必要になります。
自前アーキテクチャ構成で API を追加する際は、既存 API のソースコードやボイラープレートからの流用、もしくは、開発者独自の実装をするケースが多いでしょうか。
これらのケースにおいては、流用コストや開発者独自の実装をした際の可読性の低下・レビューコストの増加が発生する可能性もあります。
今回はこういった問題を避けるべく、Markdown 駆動で API の雛形を生成できるようにしていきます。
scaffdog とは
Markdown に仕様を定義すると、その仕様に従ってファイルを生成できる Node.js のツールです。
以下は公式に記載のコンポーネントの雛形を生成するサンプルです。
---
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 の仕様に従って、以下のファイルが生成されます。
export * from "./PrettyDog";
export type Props = React.PropsWithChildren<{}>;
export const PrettyDog: React.FC<Props> = ({ children }) => {
return <div>{children}</div>;
};
フロントエンドで用いられるケースが多い印象ですが、Node.js 環境であれば、API でも使用できます。
今回は API の雛形を生成します。
セットアップ
$ yarn add -D scaffdog
$ 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
が生成されます。
export default {
files: ["*"],
};
---
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
を基準にした出力先の候補ディレクトリ
例として、以下構成の場合、src ├── x1 │ └── y1 │ └── z1 ├── x2 │ └── y1 └── x3
生成時にサジェストから出力先を選択できるようになります。
? 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> }}
の形式で参照できる
例として、以下の定義をした場合、questions: name: 'Please enter any text.'
生成時に入力を受け付けることができ、
? Please enter any text. hoge
生成された雛形内で参照できる
`{{ inputs.name }}` // hoge
文字列で受け取った値は、Pascal Case や Camel Case に変換できたり、その他の細かい設定も可能です。詳細は公式へ。
-
ファイル名:
#
(<h1>
タグ)の形式で定義する -
ファイルの内容:コードブロックの形式で定義する
以上でセットアップは完了です。
API の雛形を生成する
セットアップで生成した Markdown を更新し、API の雛形を生成します。
今回は以下の構成を想定し、全てのファイルを生成できるようにします。
生成される各ファイルには、GET API が機能する最低限の実装がされている状態とします。
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)の記述も登場しますが、説明は省略します。
---
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 }}));
}
}
```
雛形の生成
生成準備が完了したので、生成してみます。
$ yarn scaffdog
? Please select a document. boilerplate
ℹ Output destination directory: "src"
? Please enter domain name. user
Markdown に記述した仕様に従ってファイルが生成されます(一部抜粋)
export class User {
private readonly _id: string;
constructor(id: string;) {
this._id = id;
}
}
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 など)など、様々なケースで活用できそうなので、試してみたいと思います。
以上です。