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