OpenAPIのAPI定義書をソースコードから自動生成する

ほとんどのWebサービスの開発では、Webサービスのフロントエンドを担当するフロントエンド・エンジニアとバックエンドを担当するバックエンド・エンジニアに分かれて開発します。フロントエンドは、バックエンド側が提供するHTTPベースのAPIを利用して構築されます。このため、フロントエンドの開発にはバックエンドのAPI仕様書が必須になります。

API仕様書は最近ではOpenAPI仕様のフォーマットに従って(ほとんどはyamlファイルで)書かれる事が多くなりました。ただし、バックエンドのプログラマとしてはAPIの仕様書を書くよりも、バックエンドのプログラムそのものを書きたいのが本音だと思います。そうは言っても、フロントエンド・エンジニアからはAPIの仕様を明文化してもらわないと開発しにくいとか、プロジェクトマネージャーから、インターフェースの部分の仕様だけでも先に決めてから実装すべきとかの圧力がかかる事が多いのも事実です。

そうやって仕方なく仕様書を書いていると、なぜ、Javaでプログラミングする時はJavadocで、Pythonでプログラミングする時はpydocでドキュメントを生成するのに、Web APIはエディタでyamlファイルを書いているのだろうと疑問に思えてきます。

実はSpring Bootでバックエンドの開発している場合は自動生成できるのです。本記事ではspringdoc-openapiを使って、OpenAPI仕様に従ったAPI定義書を自動生成する方法を、タスク管理管理サービスを例として解説します。

プロジェクトの作成

本記事では、Spring Bootのプロジェクトの作成から解説します。まず、Spring Initializrでプロジェクトを作成します。

下記の画面のようにプロジェクトの設定をします。 ADD DEPENDENCES... ボタンをクリックして、以下をDependenciesに追加しています。

  • Spring Web

GENERATE ボタンをクリックしてプロジェクトをダウンロードし、適切なディレクトリにzipファイルを展開します。

springdoc-openapiの定義の追加

pom.xmlタグの内部に以下の記述を追加します。

   <dependency>
      <groupId>org.springdoc</groupId>
      <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
      <version>2.0.4</version>
   </dependency>

REST APIを仮実装

タスク管理のAPIを仮実装してみましょう。

まず、フロントエンドとのデータ転送オブジェクト(Data Transfer Object: DTO)のクラスを作成します。

package com.example.demo.presentation.dto;

public record TaskResponseDto(String id, String title, String description) {
}
package com.example.demo.presentation.dto;

public record TaskRequestDto(String title, String description) {
}
package com.example.demo.presentation.dto;

import java.util.List;

public record ListTaskResponseDto(List<TaskResponseDto> results) {
}

コントローラクラスを以下のように作成します。

package com.example.demo.presentation;

import com.example.demo.presentation.dto.ListTaskResponse;
import com.example.demo.presentation.dto.TaskRequestDto;
import com.example.demo.presentation.dto.TaskResponseDto;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.Arrays;

@RestController
public class TaskController {

    @PostMapping("/tasks")
    @ResponseStatus(value = HttpStatus.CREATED)
    public TaskResponseDto createTask(@RequestBody TaskRequestDto taskCreateRequestDto) {
        return new TaskResponseDto("004", taskCreateRequestDto.title(), taskCreateRequestDto.description());
    }

    @GetMapping("/tasks/{id}")
    public TaskResponseDto getTask(@PathVariable String id) {
        return new TaskResponseDto(id, "%sのチケット".formatted(id), "%sのチケットを返す予定です".formatted(id));
    }

    @GetMapping("/tasks")
    public ListTaskResponseDto listTask() {
        return new ListTaskResponseDto(Arrays.asList(
                new TaskResponseDto("001", "1番目のタスク", "タスクの説明"),
                new TaskResponseDto("002", "2番目のタスク", "タスクの説明。その2"),
                new TaskResponseDto("003", "3番目のタスク", "タスクの説明。その3")
        ));
    }

    @PutMapping("/tasks/{id}")
    public TaskResponseDto replaceTask(@PathVariable String id, @RequestBody TaskRequestDto taskCreateRequestDto) {
        return new TaskResponseDto(id, taskCreateRequestDto.title(), taskCreateRequestDto.description());
    }

    @PatchMapping("/tasks/{id}")
    public TaskResponseDto updateTask(@PathVariable String id, @RequestBody TaskRequestDto taskRequestDto) {
        final TaskResponseDto taskResponseDto = new TaskResponseDto(id,
                taskRequestDto.title() == null ? "古いタイトル" : taskRequestDto.title(),
                taskRequestDto.description() == null ? "古い説明" : taskRequestDto.description());
        return taskResponseDto;
    }

    @DeleteMapping("tasks/{id}")
    void deleteTask(@PathVariable String id) {
        // TODO 実装が必要ですが、とりあえず成功のレスポンスにします。
    }
}

API定義のアノテーションを追加

APIを仮実装できたので、アノテーションを加えていきます。

まず、アプリケーションのクラスにAPI仕様のタイトル等のアノテーションを追記します。

@SpringBootApplication
@OpenAPIDefinition(
        info = @Info(title = "タスクAPI仕様", description = "タスクデータのAPI仕様です", version = "0.1.0",
                license = @License(name = "Apache License 2.0", url = "https://www.apache.org/licenses/LICENSE-2.0.txt")),
        servers = {@Server(description = "Production Server", url = "https://demo.example.com")})
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

コントローラクラスにAPIのパスとオペレーションのアノテーションを追記します。

@RestController
public class TaskController {

    @PostMapping("/tasks")
    @Operation(summary = "タスクを作成をする", tags = {"tasks"}, description = "タスクを作成します。",
            responses = {@ApiResponse(responseCode = "201", description = "作成されました。",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = TaskResponseDto.class)))})
    @ResponseStatus(value = HttpStatus.CREATED)
    public TaskResponseDto createTask(@RequestBody TaskRequestDto taskCreateRequestDto) {
        return new TaskResponseDto("004", taskCreateRequestDto.title(), taskCreateRequestDto.description());
    }

    @GetMapping("/tasks/{id}")
    @Operation(summary = "タスクを取得する。", tags = {"tasks"}, description = "IDを指定してタスクを取得します。",
            responses = {@ApiResponse(responseCode = "200", description = "OK",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = TaskResponseDto.class)))})
    public TaskResponseDto getTask(@Parameter(description = "タスクのID", required = true) @PathVariable String id) {
        return new TaskResponseDto(id, "%sのチケット".formatted(id), "%sのチケットを返す予定です".formatted(id));
    }

    @GetMapping("/tasks")
    @Operation(summary = "タスクリストを取得する", tags = {"tasks"}, description = "タスクのリストを取得します。",
            responses = {@ApiResponse(responseCode = "200", description = "OK",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = ListTaskResponseDto.class)))})
    public ListTaskResponseDto listTask() {
        return new ListTaskResponseDto(Arrays.asList(
                new TaskResponseDto("001", "1番目のタスク", "タスクの説明"),
                new TaskResponseDto("002", "2番目のタスク", "タスクの説明。その2"),
                new TaskResponseDto("003", "3番目のタスク", "タスクの説明。その3")
        ));
    }

    @PutMapping("/tasks/{id}")
    @Operation(summary = "タスクを更新する", tags = {"tasks"}, description = "タスクのリクエストされたプロパティを更新します。",
            responses = {@ApiResponse(responseCode = "200", description = "OK",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = TaskResponseDto.class)))})
    public TaskResponseDto replaceTask(@Parameter(description = "タスクのID", required = true) @PathVariable String id,
                                       @RequestBody TaskRequestDto taskCreateRequestDto) {
        return new TaskResponseDto(id, taskCreateRequestDto.title(), taskCreateRequestDto.description());
    }

    @PatchMapping("/tasks/{id}")
    @Operation(summary = "タスクを置換する", tags = {"tasks"}, description = "タスクのすべてをリクエストされた値に置換します",
            responses = {@ApiResponse(responseCode = "200", description = "OK",
                    content = @Content(mediaType = "application/json",
                            schema = @Schema(implementation = TaskResponseDto.class)))})
    public TaskResponseDto updateTask(@Parameter(description = "タスクのID", required = true) @PathVariable String id,
                                      @RequestBody TaskRequestDto taskRequestDto) {
        TaskResponseDto taskResponseDto = new TaskResponseDto(id,
                taskRequestDto.title() == null ? "古いタイトル" : taskRequestDto.title(),
                taskRequestDto.description() == null ? "古い説明" : taskRequestDto.description());
        return taskResponseDto;
    }

    @DeleteMapping("tasks/{id}")
    @Operation(summary = "タスクを削除する。", tags = {"tasks"}, description = "指定されたIDのタスクを削除します。",
            responses = {@ApiResponse(responseCode = "200", description = "OK")})
    void deleteTask(@Parameter(description = "タスクのID", required = true) @PathVariable String id) {
        // TODO 実装が必要ですが、とりあえず成功のレスポンスにします。
    }
}

最後にDTOクラスのスキーマアノテーションを追記します。

@Schema(description = "タスクデータ")
public record TaskResponseDto(@Schema(description = "ID") String id,
                              @Schema(description = "タスクの件名です") String title,
                              @Schema(description = "タスクの詳細な説明です。") String description) {
}
@Schema(description = "タスクデータ")
public record TaskRequestDto(@Schema(description = "タスクの件名です") String title,
                             @Schema(description = "タスクの詳細な説明です。") String description) {
}
@Schema(description = "タスクリストデータ")
public record ListTaskResponseDto(List<TaskResponseDto> results) {
}

その他のアノテーションの詳細については、swagger-coreプロジェクトのWikiを参照してください。また、JSR-303 bean validation annotationも使えます。

API定義を取得する

以下のコマンドをプロジェクトの直下のディレクトリで実行するか、IDEを使って、アプリケーション・サーバを実行します。

./mvnw spring-boot:run

アプリケーション・サーバが起動したら、以下のコマンドを実行してAPI定義のYAMLファイルを取得します。

$ curl http://localhost:8080/v3/api-docs.yaml
openapi: 3.0.1
info:
  title: タスクAPI仕様
  description: タスクデータのAPI仕様です
  license:
    name: Apache License 2.0
    url: https://www.apache.org/licenses/LICENSE-2.0.txt
  version: 0.1.0
servers:
- url: https://demo.example.com
  description: Production Server
paths:
  /tasks/{id}:
    get:
      tags:
      - tasks
      summary: タスクを取得する。
      description: IDを指定してタスクを取得します。
      operationId: getTask
      parameters:
      - name: id
        in: path
        description: タスクのID
        required: true
        schema:
          type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponseDto'
    put:
      tags:
      - tasks
      summary: タスクを更新する
      description: タスクのリクエストされたプロパティを更新します。
      operationId: replaceTask
      parameters:
      - name: id
        in: path
        description: タスクのID
        required: true
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskRequestDto'
        required: true
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponseDto'
    delete:
      tags:
      - tasks
      summary: タスクを削除する。
      description: 指定されたIDのタスクを削除します。
      operationId: deleteTask
      parameters:
      - name: id
        in: path
        description: タスクのID
        required: true
        schema:
          type: string
      responses:
        "200":
          description: OK
    patch:
      tags:
      - tasks
      summary: タスクを置換する
      description: タスクのすべてをリクエストされた値に置換します
      operationId: updateTask
      parameters:
      - name: id
        in: path
        description: タスクのID
        required: true
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskRequestDto'
        required: true
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponseDto'
  /tasks:
    get:
      tags:
      - tasks
      summary: タスクリストを取得する
      description: タスクのリストを取得します。
      operationId: listTask
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListTaskResponseDto'
    post:
      tags:
      - tasks
      summary: タスクを作成をする
      description: タスクを作成します。
      operationId: createTask
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskRequestDto'
        required: true
      responses:
        "201":
          description: 作成されました。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponseDto'
components:
  schemas:
    TaskResponseDto:
      required:
      - title
      type: object
      properties:
        id:
          type: string
          description: ID
        title:
          maxLength: 125
          minLength: 1
          type: string
          description: タスクの件名です
        description:
          maxLength: 4096
          minLength: 0
          type: string
          description: タスクの詳細な説明です。
      description: タスクデータ
    TaskRequestDto:
      required:
      - title
      type: object
      properties:
        title:
          maxLength: 125
          minLength: 1
          type: string
          description: タスクの件名です
        description:
          maxLength: 4096
          minLength: 0
          type: string
          description: タスクの詳細な説明です。
      description: タスクデータ
    ListTaskResponseDto:
      type: object
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/TaskResponseDto'
      description: タスクリストデータ

ブラウザで http://localhost:8080/swagger-ui.html を開くと以下のようにAPI仕様書のHTMLファイルとして見る事ができます。

この時点で、スタブ・サーバが完成している事になります。以下のようにリクエストすればデータの取得もできます。(以下ではjqで取得したJSONデータを整形しています)

$ curl -s http://localhost:8080/tasks | jq .
{
  "results": [
    {
      "id": "001",
      "title": "1番目のタスク",
      "description": "タスクの説明"
    },
    {
      "id": "002",
      "title": "2番目のタスク",
      "description": "タスクの説明。その2"
    },
    {
      "id": "003",
      "title": "3番目のタスク",
      "description": "タスクの説明。その3"
    }
  ]
}

以上のように、springdoc-openapiを使うと、コードの概略とアノテーションを書くだけで、フロントエンド開発者が利用できるAPI仕様書とスタブ・サーバを作成できるようになります。