0
0

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

Posted at

新しい 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 の仕様に従って、以下のファイルが生成されます。

PrettyDog/index.ts
export * from "./PrettyDog";
PrettyDog/PrettyDog.tsx
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 が生成されます。

.scaffdog/config.js
export default {
  files: ["*"],
};
.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:雛形の出力先のルート

  • outputrootを基準にした出力先の候補ディレクトリ
    例として、以下構成の場合、

      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
    
  • ignoreoutputから除外したいディレクトリ

  • 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)の記述も登場しますが、説明は省略します。

.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 }}));
  }
}
```

雛形の生成

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

$ yarn scaffdog

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

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

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

  constructor(id: string;) {
    this._id = id;
  }
}
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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0