こんにちは、エンジニアリンググループの福林 (@fukubaya) です。
先月から、今年の秋くらいにリリース予定の新サービスの設計、開発を始めました。 せっかく新しく始めるサービスなので、まだ経験したことがない言語やフレームワーク、技術を使わないと楽しくありません。
そこで、バックエンドにGoにして、フロントのAPIまで含めてgRPCの .proto
ファイルで定義を一元化し、APIコードは protoc
で生成させる計画を立てていたのですが、
- フロントでgRPCとなると、 gRPC-web か grpc-gateway になるが、リリースまでに使える期間では認証も含めると検証が間に合わなさそう
- Goだけでなく、terraform(インフラ設計もやります) も Vue.jsも今回が初めて、というメンバーもおり、さらにRESTではなくgRPCも、となると未経験技術が多すぎてキャッチアップが追いつかなさそう
ということもあって見送りました。
それでも何かしら新しく触る言語やフレームワークは入れたいので、今回は バックエンドにSpringBoot(Kotlin)を、フロントエンドに Vue.js(Typescript)を使うことになりました*1。gRPCは見送ったので、RESTのAPI定義一元化、コード自動生成を実現するためOpenAPIを利用することにしました。
OpenAPI
OpenAPI Specificationは、REST APIのためのAPI定義フォーマットです。 元々はSwaggerからスタートしたものですが、2016年から分離してOpenAPIとなりました。 2017年7月に3.0がリリースされています。
API定義の記述
API定義はYAMLかJSONで書けます。 定義が深くなるとYAMLよりJSONの方が書きやすいですが、JSONだとコメントが書けないので、どちらも一長一短です。 今回はYAMLの例を示します。
定義の作成には、Swaggerが提供しているEditorが便利です。 dockerで起動するものもありますし、オンラインでも試せます。
docker run -d -p 80:8080 swaggerapi/swagger-editor
OpenAPIの詳細仕様は以下を参考に。
paths
APIのendpointと、必要なパラメータ、レスポンスを定義します。 以下は、GETでリストを返す例です。
paths: /todos/list: get: tags: - todos summary: TODOのリスト operationId: listTodos parameters: - name: offset in: query description: offset (デフォルト0) required: false schema: type: integer format: int32 - name: limit in: query description: 取得件数 (デフォルト10) required: false schema: type: integer format: int32 responses: '200': description: TODOのリスト返す content: application/json: schema: $ref: "#/components/schemas/ApiTodos"
queryパラメータだけでなく、pathパラメータも指定できます。
paths: /todos/{id}: get: tags: - todos summary: TODOの取得 operationId: getTodo parameters: - name: id in: path description: ID required: true schema: type: integer format: int32 responses: '200': description: TODOを返す content: application/json: schema: $ref: "#/components/schemas/ApiTodo"
もちろんPOSTなどGET以外のAPIも定義できます。
paths: /todos/post: post: tags: - todos summary: 新たにTODOを追加する operationId: postTodo requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ApiTodoPostRequest" responses: '201': description: 作成完了
定義を書いていく上で以下2点が分かりにくかったので補足しておきます。
tags
: APIをグループ化するために指定します。上記の例では全てtodosグループにまとめられます。省略するとすべてdefault
にされてしまうので、何かは指定した方がいいです。operationId
: APIによる操作(operation)を識別するためのIDです。大文字小文字は区別されます。後に生成するコードでこのIDがそのままメソッド名として使われるので、それを意識した命名にするとよいです。
components
API定義で繰り返し出現するような再利用可能なobjectを定義します。
上記の例では ApiTodo
, ApiTodos
, ApiTodoPostRequest
など、 $ref
で参照しているものです。
以下の例では schemas
だけを示しますが、 parameters
や responses
も定義できます。
API専用のSchemaであることを強調するため、すべて Api
を先頭につけています*2。
components: schemas: ApiTodo: description: TODO required: - id - title properties: id: description: ID type: integer format: int32 title: description: タイトル type: string ApiTodos: description: TODOのリスト type: array items: $ref: "#/components/schemas/ApiTodo" ApiTodoPostRequest: description: TODO生成リクエスト required: - title properties: title: description: title type: string
これだけでAPI定義は完成です。
コードの生成
コードの生成はOpenAPI Generatorで行います。
# npm % npm install @openapitools/openapi-generator-cli -g # Homebrew % brew install openapi-generator
対応している言語の一覧はここにあります。 同じ言語でもフレームワークごとに別のGeneratorが用意されている言語もあります。
フロント例: typescript-axios
typescript
で通信は axios
を使うコードを生成してみます。
% openapi-generator generate \ -g typescript-axios \ # 生成する言語 -i ./api.yml \ # API定義 -o ./ts \ # 出力先ディレクトリ --api-package=api \ # API定義のディレクトリ --model-package=model \ # モデル(schema)定義のディレクトリ --additional-properties=withSeparateModelsAndApi=true # 言語ごとに指定できるパラメータ(モデルとAPIを別ファイルにする) % tree ts ts ├── api │ └── todos-api.ts ├── api.ts ├── base.ts ├── configuration.ts ├── custom.d.ts ├── git_push.sh ├── index.ts └── model ├── api-error.ts ├── api-todo-post-request.ts ├── api-todo.ts └── index.ts
ApiTodo
は以下のように生成されました(後で説明する方法でフォーマットしてあります)。
もちろん型もあります。
/** * TODO * @export * @interface ApiTodo */ export interface ApiTodo { /** * ID * @type {number} * @memberof ApiTodo */ id: number; /** * タイトル * @type {string} * @memberof ApiTodo */ title: string; }
APIクライアントも同じく生成されます。
/** * TodosApi - object-oriented interface * @export * @class TodosApi * @extends {BaseAPI} */ export class TodosApi extends BaseAPI { /** * * @summary TODOの取得 * @param {number} id ID * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof TodosApi */ public getTodo(id: number, options?: any) { return TodosApiFp(this.configuration).getTodo(id, options)( this.axios, this.basePath ); } /** * * @summary TODOのリスト * @param {number} [offset] offset (デフォルト0) * @param {number} [limit] 取得件数 (デフォルト10) * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof TodosApi */ public listTodos(offset?: number, limit?: number, options?: any) { return TodosApiFp(this.configuration).listTodos(offset, limit, options)( this.axios, this.basePath ); } /** * * @summary 新たにTODOを追加する * @param {ApiTodoPostRequest} apiTodoPostRequest * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof TodosApi */ public postTodo(apiTodoPostRequest: ApiTodoPostRequest, options?: any) { return TodosApiFp(this.configuration).postTodo(apiTodoPostRequest, options)( this.axios, this.basePath ); } }
生成されたコード自体はimportするだけでよいので、元のコードを触ることなく利用できます。
import { AxiosResponse } from "axios"; import { ApiTodo } from "./ts/model/api-todo"; import { TodosApi } from "./ts/api/todos-api"; let client: TodosApi = new TodosApi(); client.listTodos(0, 10) .then((res: AxiosResponse<ApiTodo[]>)=> { let todos: Array<ApiTodo> = res.data; for (const t of todos) { console.log(t); } }) .catch((error: any) => { console.log(error); });
バックエンド例: kotlin-spring
SpringBoot向けのKotlinのコードを生成してみます。
% openapi-generator generate \ -g kotlin-spring \ -i ./api.yml \ -o . \ --additional-properties=library=spring-boot,basePackage=com.m3.todo,apiSuffix=ApiController,gradleBuildFile=false,serviceInterface=true \ --api-package=com.m3.todo.adapter.restapi.controller \ --model-package=com.m3.todo.adapter.restapi.model % tree src src ├── main │ ├── kotlin │ │ └── com │ │ └── m3 │ │ └── todo │ │ ├── Application.kt │ │ └── adapter │ │ └── restapi │ │ ├── controller │ │ │ ├── Exceptions.kt │ │ │ ├── TodosApiController.kt │ │ │ └── TodosApiControllerService.kt │ │ └── model │ │ ├── ApiError.kt │ │ ├── ApiTodo.kt │ │ └── ApiTodoPostRequest.kt │ └── resources │ └── application.yaml └── test └── kotlin └── com └── m3 └── todo └── adapter └── restapi └── controller └── TodosApiControllerTest.kt
ApiTodo
は data class
として定義されました。
package com.m3.todo.adapter.restapi.model import com.fasterxml.jackson.annotation.JsonProperty import javax.validation.constraints.NotNull /** * TODO * @param id ID * @param title タイトル */ data class ApiTodo( @get:NotNull @JsonProperty("id") val id: kotlin.Int, @get:NotNull @JsonProperty("title") val title: kotlin.String )
controllerは interface
として定義された TodosApiControllerService
を injection する構成にしました。
デフォルトの設定だと interface
にならないので、生成時に serviceInterface=true
を指定しています。
interface
にすることで、この controller 自体は触らず、別途 TodosApiControllerService
の実装を書くだけでよくなります。
package com.m3.todo.adapter.restapi.controller ... @RestController @Validated @RequestMapping("\${api.base-path:/api/v1}") class TodosApiControllerController(@Autowired(required = true) val service: TodosApiControllerService) { @RequestMapping( value = ["/todos/{id}"], produces = ["application/json"], method = [RequestMethod.GET]) fun getTodo( @PathVariable("id") id: kotlin.Int ): ResponseEntity<ApiTodo> { return ResponseEntity(service.getTodo(id), HttpStatus.valueOf(200)) } @RequestMapping( value = ["/todos/list"], produces = ["application/json"], method = [RequestMethod.GET]) fun listTodos( @RequestParam(value = "offset", required = false) offset: kotlin.Int?, @RequestParam(value = "limit", required = false) limit: kotlin.Int? ): ResponseEntity<List<ApiTodo>> { return ResponseEntity(service.listTodos(offset, limit), HttpStatus.valueOf(200)) } @RequestMapping( value = ["/todos/post"], produces = ["application/json"], consumes = ["application/json"], method = [RequestMethod.POST]) fun postTodo( @Valid @RequestBody apiTodoPostRequest: ApiTodoPostRequest ): ResponseEntity<Unit> { return ResponseEntity(service.postTodo(apiTodoPostRequest), HttpStatus.valueOf(201)) } }
困ったことと対処
これだけで生成はできるのですが、細かいところで困りました。
生成されるファイルがコードフォーマットルールに合わない
生成時のメッセージにも表示されていますが、生成後の処理でコードのフォーマットをさせることができます。 OpenAPI Generatorが生成するコードは基本的に mustache によるテンプレートで生成されているので、 必ずしもフォーマットが各言語で推奨されるルールに従っている訳ではありません。 そこで、環境変数とオプションで生成後の処理を別途指定します。
typescript-axios
の場合。
# 処理後に prettier でフォーマット % export TS_POST_PROCESS_FILE="$(which prettier) --write" % openapi-generator generate \ -g typescript-axios \ -i ./api.yml \ -o ./ts \ --api-package=api \ --model-package=model \ --additional-properties=withSeparateModelsAndApi=true \ --enable-post-process-file # 生成後の処理を実行する
kotlin-spring
の場合。
# 処理後に ktlint でフォーマット % export KOTLIN_POST_PROCESS_FILE="$(which ktlint) -F" % openapi-generator generate \ -g kotlin-spring \ -i ./api.yml \ -o . \ --additional-properties=library=spring-boot,basePackage=com.m3.todo,apiSuffix=ApiController,gradleBuildFile=false,serviceInterface=true \ --api-package=com.m3.todo.adapter.restapi.controller \ --model-package=com.m3.todo.adapter.restapi.model \ --enable-post-process-file # 生成後の処理を実行する
いらないファイルが生成される
generatorが気を利かせて本体コード以外にファイルも生成しますが、不要なファイルもあります。 そのため、手動で作ったファイルを上書きされてしまったりします。
生成が不要なファイルは .openapi-generator-ignore
に列挙して、出力先のディレクトリのトップに置いておくと
生成されなくなります。
typescript-axios
の場合の例。
# OpenAPI Generator Ignore # Generated by openapi-generator https://github.com/openapitools/openapi-generator # Use this file to prevent files from being overwritten by the generator. # The patterns follow closely to .gitignore or .dockerignore. git_push.sh
kotlin-spring
の場合の例。
# OpenAPI Generator Ignore # Generated by openapi-generator https://github.com/openapitools/openapi-generator # Use this file to prevent files from being overwritten by the generator. # The patterns follow closely to .gitignore or .dockerignore. README.md pom.xml build.gradle.kts build.gradle settings.gradle src/main/resources/application.yaml src/main/kotlin/com/m3/todo/Application.kt src/test/
生成ファイルをカスタマイズしたい
生成されるファイルをカスタマイズしたい場合があります。 例えば、すべてのControllerで共通的な処理を挟みたい、などです。
先ほど少し触れたように、コードはmustacheのテンプレートで生成しているので、 このテンプレートをイジればある程度カスタマイズが可能です。
まずはオリジナルのテンプレートをディレクトリごとコピーしておきます。
例えば、API処理の前に必ずログを記録する処理を入れてみます*3。 kotlin-spring/api.mustache
を編集します。
4a5 > import com.m3.todo.base.service.LoggingService 31a33 > import javax.service.http.HttpServletRequest 60c62 < class {{classname}}Controller({{#serviceInterface}}@Autowired(required = true) val service: {{classname}}Service{{/serviceInterface}}) { --- > class {{classname}}Controller({{#serviceInterface}}@Autowired val loggingSerice: LoggingService, @Autowired(required = true) val service: {{classname}}Service{{/serviceInterface}}) { 80c82,83 < {{#reactive}}{{^isListContainer}}suspend {{/isListContainer}}{{/reactive}}fun {{operationId}}({{#allParams}}{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>bodyParams}}{{>formParams}}{{#hasMore}},{{/hasMore}}{{/allParams}}): ResponseEntity<{{>returnTypes}}> { --- > {{#reactive}}{{^isListContainer}}suspend {{/isListContainer}}{{/reactive}}fun {{operationId}}({{#allParams}}{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>bodyParams}}{{>formParams}},{{/allParams}} httpServletRequest: Httpservletrequest): ResponseEntity<{{>returnTypes}}> { > loggingService.info(httpservletrequest)
ちょっと分かりづらいですが、LoggingService
の injection、 HttpServletRequest
を引数に追加、 loggingService.info(httpServletRequest)
をAPI処理の前に追加しています。
生成には -t
でテンプレートのディレクトリを指定します。
% openapi-generator generate -g kotlin-spring ... -t mod-kotlin-spring # テンプレートディレクトリの指定
テンプレートの変更が反映されました。
@RestController @Validated @RequestMapping("\${api.base-path:/api/v1}") class TodosApiControllerController(@Autowired val loggingSerice: LoggingService, @Autowired(required = true) val service: TodosApiControllerService) { @RequestMapping( value = ["/todos/{id}"], produces = ["application/json"], method = [RequestMethod.GET]) fun getTodo( @PathVariable("id") id: kotlin.Int, httpServletRequest: Httpservletrequest ): ResponseEntity<ApiTodo> { loggingService.info(httpservletrequest) return ResponseEntity(service.getTodo(id), HttpStatus.valueOf(200)) }
axiosがdateをparseしてくれない
OpenAPIでは日付、日時もRFC3339に合わせてfull-date
, date-time
を定義できます。
kotlin-spring
では java.time.OffsetDateTime
として定義されます*4。
package com.m3.todo.adapter.restapi.model import com.fasterxml.jackson.annotation.JsonProperty /** * 時刻 * @param datetimte 時刻 */ data class ApiDatetime( @JsonProperty("datetimte") val datetimte: java.time.OffsetDateTime? = null )
typescript-axios
でも Date
として定義されます。
/** * 時刻 * @export * @interface ApiDatetime */ export interface ApiDatetime { /** * 時刻 * @type {Date} * @memberof ApiDatetime */ datetimte?: Date; }
しかし、実際にサーバから受け取るとISO8601形式の string
のまま渡されてしまいます。
せっかくAPIコードが自動生成されて、通信やparse処理を一切気にしなくてよくなったのにこれは惜しい。
そこで、axios
の interceptor
の機能を使って response
を then
や catch
に渡す前に自前の処理を挟んで、
ここで受信したJSONを検査してDateに変換することにしました。
import axios, { AxiosResponse } from "axios"; function isArray(item: any): boolean { return item && typeof item === "object" && item.constructor === Array; } function isObject(item: any): boolean { return item && typeof item === "object" && item.constructor === Object; } function isString(item: any): boolean { return typeof item === "string" || item instanceof String; } const ISO_8601_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)/; /** * ISO8601 パターンの日付をDateオブジェクトに変換する */ function parseDateValues(item: any) { if (isArray(item)) { return item.map((i: any) => parseDateValues(i)); } if (!isObject(item)) { if (isString(item) && ISO_8601_PATTERN.test(item)) { return new Date(item); } return item; } const newObj = new Object(); Object.keys(item).map(key => { Object.defineProperty(newObj, key, { value: parseDateValues(item[key]), writable: true, enumerable: true, configurable: true }); }); return newObj; } const apiAxios = axios.create(); apiAxios.interceptors.response.use((response: AxiosResponse<any>) => { response.data = parseDateValues(response.data); return response; });
入ってきたJSONのvalueが string
で、さらにISO8601のパターンに
マッチしたら Date
に変換する実装になっています。
マッチしたらすべて変換してしまうので、key名に条件をつけるなど、さらに制約を加えた方が安全かもしれません。
このカスタマイズした axios
を APIクライアント生成時に渡すと、interceptorで変換処理が実行されます。
let client: TodosApi = new TodosApi({}, "/api/v1", apiAxios);
We are hiring!
冒頭で紹介したように、現在新サービス立ち上げの真っ最中です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。
*1:Typescriptは前プロジェクトで諦めていたので今回入れたかった https://www.m3tech.blog/entry/2019/04/23/114832
*2:主にバックエンドでAPIのスキーマとbusiness logicのモデルが混ざらないようにするため
*3:loggingだったらinterceptorとかでもいい
*4:オプションで指定すれば別のclassも指定できます https://openapi-generator.tech/docs/usage#examples