springdoc-openapiでOpenAPI形式のAPIドキュメントを生成する

こんにちは! みなさん、API Spec書いてますか?私はお仕事で、OpenAPI(Swagger)形式のYAMLを書いています!

ソースコードとドキュメントを別にしてしまうと、実装途中で仕様変更になったときにyml書き直すのが手間だったり、APIの実装と合ってない箇所があったりなどの問題が発生してしまいます。(そもそもyml書くの辛いし)

プロダクションコードに手を加えることなくプロダクションコードをベースにOpenAPI形式のAPIドキュメントを吐き出すライブラリを探していました。 いくつかそれっぽいライブラリはあるのですが、プロダクトションコードに設定書かないといけなかったり、テストに設定書かないといけないし(テストコードが全部あるわけじゃない)で要望を完全に満たしてもらえませんでした... 最近、完全に要望を満たしてくれる springdoc-openapi というライブラリを見つけましたので紹介します!!!!!

springdoc-openapiとは

springdoc-openapiは、Spring Bootを利用しているプロジェクトで簡単にOpenAPI形式のAPIドキュメントを生成してくれるライブラリです。 Web MVC, WebFluxともに対応しているようです。

サポートしているライブラリ
- Open API 3
- Spring Boot(v1 and v2)
- JSR-303, specifically for @NotNull, @Min, @Max, and @Size.
- Swagger-ui
- OAuth 2

サンプルコード

今回は、Web MVCでのサンプルを紹介します。

API

SakeController.java

@RestController
@RequestMapping("/sakes")
@RequiredArgsConstructor
public class SakeController {

    private final SakeService sakeService;

    @GetMapping
    public Page<SakeResponse> page(@PageableDefault Pageable pageable) {
        return sakeService.page(pageable);
    }

    @GetMapping("list")
    public List<SakeResponse> list() {
        return sakeService.list();
    }

    @GetMapping("{id}")
    public SakeResponse get(@PathVariable Integer id) {
        return sakeService.get(id);
    }

    @PostMapping
    public SakeResponse create(@RequestBody @Validated SakeCreateRequest request) {
        return sakeService.create(request);
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ErrorResponse> handleException(NotFoundException e) {
        return ResponseEntity.of(Optional.of(new ErrorResponse(e.getMessage())));
    }
}

SakeResponse.java

@Value
public class SakeResponse {
    Integer id;
    String name;
    String brewingName;

    public static SakeResponse newInstance(Sake sake) {
        return new SakeResponse(sake.getId(), sake.getName(), sake.getBrewingName());
    }
}

SakeCreateRequest.java

@Value
public class SakeCreateRequest {
    @NotNull
    @Size(min = 1, max = 20)
    String name;
    @NotNull
    String brewingName;
}

GlobalControllerHandler.java

@RestControllerAdvice
public class GlobalControllerHandler {

    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ResponseEntity<ErrorResponse> handleNotFoundException(RuntimeException e) {
        return ResponseEntity.of(Optional.of(new ErrorResponse(e.getMessage())));
    }
}

ただのController周りの実装なので、説明は省略します。

springdoc-openapiの導入

build.gradle

dependencies {
  ...
  
    implementation 'org.springdoc:springdoc-openapi-ui:1.4.6'
    
  ...
}

この1行追加します。

アプリケーションの起動

アプリケーションを起動して、デフォルトで用意されているURLにアクセスすると、それぞれjson, yaml, htmlを返してくれすます。

GET http://localhost:8080/v3/api-docs/

{
    "openapi": "3.0.1",
    "info": {
        "title": "OpenAPI definition",
        "version": "v0"
    },
    "servers": [
        {
            "url": "http://localhost:8080/api",
            "description": "Generated server url"
        }
    ],
    "paths": {
        "/sakes/{id}": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "get",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "integer",
                            "format": "int32"
                        }
                    }
                ],
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/SakeResponse"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/sakes/list": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "list",
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "type": "array",
                                    "items": {
                                        "$ref": "#/components/schemas/SakeResponse"
                                    }
                                }
                            }
                        }
                    }
                }
            }
        },
        "/sakes": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "page",
                "parameters": [
                    {
                        "name": "pageable",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "$ref": "#/components/schemas/Pageable"
                        }
                    }
                ],
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/PageSakeResponse"
                                }
                            }
                        }
                    }
                }
            },
            "post": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "create",
                "requestBody": {
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/SakeCreateRequest"
                            }
                        }
                    },
                    "required": true
                },
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/SakeResponse"
                                }
                            }
                        }
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "ErrorResponse": {
                "type": "object",
                "properties": {
                    "message": {
                        "type": "string"
                    }
                }
            },
            "SakeResponse": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "name": {
                        "type": "string"
                    },
                    "brewingName": {
                        "type": "string"
                    }
                }
            },
            "SakeCreateRequest": {
                "required": [
                    "brewingName",
                    "name"
                ],
                "type": "object",
                "properties": {
                    "name": {
                        "maxLength": 20,
                        "minLength": 1,
                        "type": "string"
                    },
                    "brewingName": {
                        "type": "string"
                    }
                }
            },
            "Pageable": {
                "type": "object",
                "properties": {
                    "offset": {
                        "type": "integer",
                        "format": "int64"
                    },
                    "sort": {
                        "$ref": "#/components/schemas/Sort"
                    },
                    "pageNumber": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "pageSize": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "paged": {
                        "type": "boolean"
                    },
                    "unpaged": {
                        "type": "boolean"
                    }
                }
            },
            "Sort": {
                "type": "object",
                "properties": {
                    "sorted": {
                        "type": "boolean"
                    },
                    "unsorted": {
                        "type": "boolean"
                    },
                    "empty": {
                        "type": "boolean"
                    }
                }
            },
            "PageSakeResponse": {
                "type": "object",
                "properties": {
                    "totalPages": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "totalElements": {
                        "type": "integer",
                        "format": "int64"
                    },
                    "size": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "content": {
                        "type": "array",
                        "items": {
                            "$ref": "#/components/schemas/SakeResponse"
                        }
                    },
                    "numberOfElements": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "sort": {
                        "$ref": "#/components/schemas/Sort"
                    },
                    "first": {
                        "type": "boolean"
                    },
                    "pageable": {
                        "$ref": "#/components/schemas/Pageable"
                    },
                    "last": {
                        "type": "boolean"
                    },
                    "number": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "empty": {
                        "type": "boolean"
                    }
                }
            }
        }
    }
}

GET http://localhost:8080/v3/api-docs.yaml

openapi: 3.0.1
info:
  title: OpenAPI definition
  version: v0
servers:
- url: http://localhost:8080/api
  description: Generated server url
paths:
  /sakes/{id}:
    get:
      tags:
      - sake-controller
      operationId: get
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          format: int32
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/SakeResponse'
  /sakes/list:
    get:
      tags:
      - sake-controller
      operationId: list
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SakeResponse'
  /sakes:
    get:
      tags:
      - sake-controller
      operationId: page
      parameters:
      - name: pageable
        in: query
        required: true
        schema:
          $ref: '#/components/schemas/Pageable'
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/PageSakeResponse'
    post:
      tags:
      - sake-controller
      operationId: create
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SakeCreateRequest'
        required: true
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/SakeResponse'
components:
  schemas:
    ErrorResponse:
      type: object
      properties:
        message:
          type: string
    SakeResponse:
      type: object
      properties:
        id:
          type: integer
          format: int32
        name:
          type: string
        brewingName:
          type: string
    SakeCreateRequest:
      required:
      - brewingName
      - name
      type: object
      properties:
        name:
          maxLength: 20
          minLength: 1
          type: string
        brewingName:
          type: string
    Pageable:
      type: object
      properties:
        offset:
          type: integer
          format: int64
        sort:
          $ref: '#/components/schemas/Sort'
        pageNumber:
          type: integer
          format: int32
        pageSize:
          type: integer
          format: int32
        paged:
          type: boolean
        unpaged:
          type: boolean
    Sort:
      type: object
      properties:
        sorted:
          type: boolean
        unsorted:
          type: boolean
        empty:
          type: boolean
    PageSakeResponse:
      type: object
      properties:
        totalPages:
          type: integer
          format: int32
        totalElements:
          type: integer
          format: int64
        size:
          type: integer
          format: int32
        content:
          type: array
          items:
            $ref: '#/components/schemas/SakeResponse'
        numberOfElements:
          type: integer
          format: int32
        sort:
          $ref: '#/components/schemas/Sort'
        first:
          type: boolean
        pageable:
          $ref: '#/components/schemas/Pageable'
        last:
          type: boolean
        number:
          type: integer
          format: int32
        empty:
          type: boolean

GET http://localhost:8080/swagger-ui.html

これだけで、APIドキュメントが生成されるなんて感動しませんか? Controllerクラスや@RestControllerAdviceが付与されているクラスの@ExceptionHandlerを読み取ってレスポンスに適用してくれます。

@NotNull@NotBlank@Size@Min@Max のような、JSR-303 Bean Validationのアノテーションについても適用してくれます。

カスタマイズ

application.properties に書けば、デフォルト設定を変更できます。設定値一覧は、springdoc-propertiesを参考にしてください。

設定値の抜粋です。値はデフォルト値です。

- JSONのOpenAPIドキュメントのパス
  - springdoc.api-docs.path=/v3/api-docs
- OpenAPIエンドポイントの有効・無効
  - springdoc.api-docs.enabled=true
- `@ControllerAdvice` が付与されたクラスに書かれたレスポンスを全レスポンスに適用するかどうか
  - springdoc.override-with-generic-response=true
- Swagger UIが適用されたHTMLドキュメント表示するパス
  - springdoc.swagger-ui.path=/swagger-ui.html

まとめ

特に設定なしにAPIドキュメントを生成してくるのはとてもよいですね。 気になったところは、タグがController名のケバブケースになってしまうところですが、 @Tag(name="sake") をControllerに付与してあげれば変更は可能でした。 springdoc-openapiでフロント、モバイルチームとのコミュニケーションが円滑に進みそうです!