こんにちは!
みなさん、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でのサンプルを紹介します。
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でフロント、モバイルチームとのコミュニケーションが円滑に進みそうです!