エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

APIのコードを自動生成させたいだけならgRPCでなくてもよくない?

こんにちは、エンジニアリンググループの福林 (@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を利用することにしました。

f:id:fukubaya:20190813220337j:plain
パシフィコ横浜は、横浜市にある世界最大級の国際会議場と展示ホールとホテルからなるコンベンション・センター。本文には特に関係ありません。

OpenAPI

OpenAPI Specificationは、REST APIのためのAPI定義フォーマットです。 元々はSwaggerからスタートしたものですが、2016年から分離してOpenAPIとなりました。 2017年7月に3.0がリリースされています。

swagger.io

API定義の記述

API定義はYAMLかJSONで書けます。 定義が深くなるとYAMLよりJSONの方が書きやすいですが、JSONだとコメントが書けないので、どちらも一長一短です。 今回はYAMLの例を示します。

定義の作成には、Swaggerが提供しているEditorが便利です。 dockerで起動するものもありますし、オンラインでも試せます。

docker run -d -p 80:8080 swaggerapi/swagger-editor

editor.swagger.io

f:id:fukubaya:20190813220423p:plain
Swagger Editor

OpenAPIの詳細仕様は以下を参考に。

swagger.io

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定義は完成です。

f:id:fukubaya:20190813221619p:plain
サンプルAPI

コードの生成

コードの生成はOpenAPI Generatorで行います。

openapi-generator.tech

# 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 に列挙して、出力先のディレクトリのトップに置いておくと 生成されなくなります。

openapi-generator.tech

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のテンプレートで生成しているので、 このテンプレートをイジればある程度カスタマイズが可能です。

まずはオリジナルのテンプレートをディレクトリごとコピーしておきます。

github.com

例えば、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に変換することにしました。

github.com

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!

冒頭で紹介したように、現在新サービス立ち上げの真っ最中です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。

open.talentio.com

jobs.m3.com

*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