開発言語・環境

  • バックエンド
  • フロントエンド
    • TypeScript 4.6
    • Nuxt.js 2.16
    • axios v0.21.4
    • OpenAPI Generator 5.4.0

※ コードを使った説明が多く出てきますが、各言語やライブラリの説明は省きますので基礎的な文法がわかることが前提となってしまいます。ご了承ください。

導入の背景

もともと仕様書の記述はOpenAPIで行っていました。 しかし、クライアントやサーバーのAPIレスポンスのコードは手動で実装していたため、コードの更新を仕様書に反映し忘れたり、仕様書とコードでプロパティ名が異なったりと、実装と仕様の乖離が発生することがありました。 OpenAPIファイルとコードを二重でメンテナンスをするコストだけがかかっている状態です。

そこでそれぞれの言語向けにコードを自動生成する方法を取り入れ、この課題を解決しました。

導入した結果

  1. 仕様書とコードが一致するようになった
  2. それぞれの言語向けにAPIレスポンスのコードを実装するコストが減った
  3. フロントエンドとバックエンドの実装が並行して進められるようになった

1、2については、APIを変更したときにコードを再生成するとGoやTypeScriptでエラーがでるので、変更が安全に行えるようになりました。

このときの開発チームでは同じ人がフロントエンドとバックエンド両方を実装していたので、3についてはあまり影響がありませんでしたが、開発者が分かれている場合はバックエンドの開発が済んでいなくてもフロントエンドの開発を進められるのは大きな利点だと思います。

一方でイマイチだった点として、生成ツールの吐き出すコードのフォーマットがプロジェクトに合わないものであっても妥協する必要がありました。 出力されるコードのフォーマットはある程度パラメータでコントロールできるものの、その方法を調べるコストやメンテナンスコストを鑑みて、生成されたものにコードフォーマッタをかけるくらいに留めてあとは受け入れることにしました。

やらなかったこと

今回はOpenAPIファイルをもとにしてコードを生成する方針で開発を行いました。

一方で、コードからOpenAPIファイルを生成するアプローチもあります。 yamlを編集するよりもコードを書きたい、コードのほうがコンパイルエラーで検知できたりIDEの恩恵を受けやすいという気持ちはあったのですが、 ライブラリ側がOpenAPI 3.0系に対応するのを待つ必要があったり、使用できるプロパティが制限されたりと不便なところがあったため、このアプローチは取りませんでした。 例えば、Go でコードからOpenAPI(Swagger)を生成するライブラリの中でスター数の多い swag はSwagger 2.0に対応していますがOpenAPI 3.0系には未対応です。

以降はサンプルとして OpenAPIのexamplesにあるpetstore.yaml を使用して説明していきます。

バックエンドのコード生成

GoのAWS Lambdaハンドラー向けのコード生成について説明します。

OpenAPIからGoのコードを生成するツールで有名なものに go-swagger がありますが、現時点(v0.30.3)ではSwagger 2.0にのみ対応しており、OpenAPI 3系が使えません。 すでにOpenAPIファイルは3系で書いていたため、これに対応している oapi-codegen を使用しました。 go-swagger と比べるとリリース頻度が低く、プルリクが滞留しがちなのが気になるところではありますが、他の選択肢がなかったためこちらを採用しています。

oapi-codegenの使い方

まずは最新版をインストールして実行してみます。

go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest
 
oapi-codegen -package "openapi" petstore.yaml > petstore.gen.go

すると以下のような内容が書かれた petstore.gen.go が生成されます。

  • componentsparameters のstruct定義
  • 全APIのハンドラーを持った ServerInterface
  • Echo用のwrapper
  • Base64エンコードされたOpenAPI spec

生成対象はconfigファイルで設定することができます。 今回はstruct定義のみ生成したかったので、以下のような設定にしました。

# oapi-codegen.yaml
 
package: openapi
generate:
  models: true
  # echo-server: true
  # embedded-spec: true
oapi-codegen -config oapi-codegen.yaml petstore.yaml > petstore.gen.go

生成されるコードはこちらです。

// Package openapi provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/deepmap/oapi-codegen version v1.11.0 DO NOT EDIT.
package openapi
 
// Error defines model for Error.
type Error struct {
	Code    int32  `json:"code"`
	Message string `json:"message"`
}
 
// Pet defines model for Pet.
type Pet struct {
	Id   int64   `json:"id"`
	Name string  `json:"name"`
	Tag  *string `json:"tag,omitempty"`
}
 
// Pets defines model for Pets.
type Pets = []Pet
 
// ListPetsParams defines parameters for ListPets.
type ListPetsParams struct {
	// How many items to return at one time (max 100)
	Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"`
}

※v1.10.0以前では、以下のようにコマンドラインオプションで生成対象を指定できましたがこれは使えなくなっています。 詳しくは v1.11.0のリリースノート をご覧ください。

oapi-codegen -generate "types" -package "openapi" petstore.yaml
oapi-codegen -generate "server" -package "openapi" petstore.yaml

AWS Lambdaのハンドラー内で利用する

ハンドラーのコードについて詳細は省きますが、 こちらのようにしてリクエストパラメータを生成コードにマッピングして、処理を行い、レスポンスを返すよう実装しました。

package main
 
import (
	"encoding/json"
	"net/http"
 
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/mitchellh/mapstructure"
 
	"example.com/petstore/openapi"
)
 
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	// requestをopenapi.ListPetsParamsにマッピングする
	var param openapi.ListPetsParams
 
	decoderConfig := &mapstructure.DecoderConfig{
		WeaklyTypedInput: true,
		Result:           &param,
	}
	decoder, err := mapstructure.NewDecoder(decoderConfig)
	if err != nil {
		return events.APIGatewayProxyResponse{}, err
	}
	err = decoder.Decode(request.QueryStringParameters)
	if err != nil {
		return events.APIGatewayProxyResponse{}, err
	}
 
	// do something
 
	// openapi.Petsを作成してJSONにして返却する
	pets := make(openapi.Pets, 0)
 
	body, err := json.Marshal(pets)
	return events.APIGatewayProxyResponse{
		StatusCode: http.StatusOK,
		Body:       string(body),
	}, err
}
 
func main() {
	lambda.Start(handler)
}

こうすることで、生成されたコードとリクエスト、レスポンスのマッピングが行われ、仕様書とコードが一致するようになりました。

struct tagを追加したい

上記コードでは、 mapstructure を使ってLambdaのリクエストパラメータ( map[string]string 型の QueryStringParameters) を openapi.ListPetsParams 型の変数にパースしています。 このライブラリは、 mapstructre:"limit" のようなstruct tagを書くことで、mapのキー名とtagが一致するフィールドに値をパースさせることができます。

これは、次のようにkebab-caseでリクエストパラメータを定義したいときに役立ちます。

  /pets:
    get:
      parameters:
        - name: limit
          in: query
          description: How many items to return at one time (max 100)
          required: false
          schema:
            type: integer
            format: int32
        - name: sort-by
          in: query
          description: How to sort items
          required: false
          schema:
            type: string

生成されるコード

type ListPetsParams struct {
	// How many items to return at one time (max 100)
	Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"`
 
	// How to sort items
	SortBy *string `form:"sort-by,omitempty" json:"sort-by,omitempty"`
}

この状態で /pets?sort-by=name でリクエストすると、SortBy には値が入らず、 /pets?sortBy=name としないといけません。

これを解消するために oapi-codegen では、x-oapi-codegen-extra-tags を書くことで任意のstruct tagをつけることができるようになっています。

        - name: sort-by
          in: query
          description: How to sort items
          required: false
          schema:
            type: string
          x-oapi-codegen-extra-tags:
            mapstructure: sort-by,omitempty

生成されるコード

type ListPetsParams struct {
	// How many items to return at one time (max 100)
	Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"`
 
	// How to sort items
	SortBy *string `form:"sort-by,omitempty" json:"sort-by,omitempty" mapstructure:"sort-by,omitempty"`
}

これで /pets?sort-by=name が期待通り働くようになりました。

他にも go-playground/validator 用のタグを追加するのにも使えそうです。

フロントエンドのコード生成

つづいてフロントエンド側のコード生成について説明します。 TypeScriptのクライアントコードは、 OpenAPI Generator を使って生成しました。

httpクライアントは axios を使っていますので、axios 向けのコードを出力するようにします。 次のようにして npm run openapi-generate で実行できるようにしました。

package.json

{
  "scripts": {
    "openapi-generate": "rm -f api_client/*.ts || true && TS_POST_PROCESS_FILE='npx prettier --write' openapi-generator-cli generate -i petstore.yaml -g typescript-axios -o api_client --additional-properties=supportsES6=true,useSingleRequestParameter=true --enable-post-process-file"
  },
  "devDependencies": {
    "@openapitools/openapi-generator-cli": "^2.4.0"
  }
}

rm -f api_client/*.ts || true

変更時に不要になったファイルが残ってしまうので、削除します

TS_POST_PROCESS_FILE=‘npx prettier —write’

TS_POST_PROCESS_FILE でコード生成後に実行する処理を設定できます。ここではprettierでコードフォーマットを行っています。

openapi-generator-cli generate -i petstore.yaml -g typescript-axios -o api_client

  • -i OpenAPIファイルを指定する
  • -g generatorの種類
  • -o 生成先のディレクトリ

指定できるgeneratorの種類はこちらで調べることができます。 https://openapi-generator.tech/docs/generators/

axiosを使用しているので typescript-axios を指定しました。fetch を使いたい場合、 typescript-fetch を指定することもできます。

—additional-properties=supportsES6=true,useSingleRequestParameter=true

指定できるオプションは公式ドキュメントに記載されています。 https://openapi-generator.tech/docs/generators/typescript-axios

  • supportsES6 ES6向けのコードを出力する
  • useSingleRequestParameter APIリクエストパラメータを構造体にまとめる

—enable-post-process-file

TS_POST_PROCESS_FILE の処理を実行するためのオプションです

生成されるクライアントコード

export class BaseAPI {
  protected configuration: Configuration | undefined
 
  constructor(
    configuration?: Configuration,
    protected basePath: string = BASE_PATH,
    protected axios: AxiosInstance = globalAxios
  ) {
    if (configuration) {
      this.configuration = configuration
      this.basePath = configuration.basePath || this.basePath
    }
  }
}
 
// ...
 
export class PetsApi extends BaseAPI {
    // ...
}

生成されたコードをVueから利用する

テストがしやすいよう、RepositoryFactoryパターンで実装しています。 主題ではないので、軽い紹介にとどめます。

// repositories/api/petstore.ts
 
import type { AxiosInstance } from 'axios'
import { PetsApi, PetsApiListPetsRequest, Pet } from '~/generated'
 
export class PetsRepository {
  private readonly axios: AxiosInstance
 
  // axiosインスタンスを差し替え可能にする
  constructor($axios: AxiosInstance) {
    this.axios = $axios
  }
 
  async list(req: PetsApiListPetsRequest): Promise<Pet[]> {
    const petsApi = new PetsApi(
      undefined,
      this.axios.defaults.baseURL,
      this.axios
    )
    const response = await petsApi.listPets(req)
    return response.data
  }
 
}
// plugins/repositories.ts
 
import { PetsRepository } from '~/repositories/api/petstore'
 
export interface RepositoryApis {
  pets: PetsRepository
}
 
export default defineNuxtPlugin((nuxtApp) => {
  // axiosインスタンスをinjectする
  const pets = new PetsRepository(nuxtApp.$axios)
 
  const repositories: RepositoryApis = {
    pets,
  }
  nuxtApp.provide('repositories', repositories)
})
// pages/petlist.vue
 
<script setup lang="ts">
import { Pet } from '~/generated'
 
const pets = ref<Pet[]>([])
 
const $nuxt = useNuxtApp()
 
const searchPets = async () => {
  // pets.list APIを呼び出し
  const resp = await $nuxt.$repositories.pets.list({
    limit: 10,
  })
  return resp
}
 
onMounted(() => searchPets())
</script>
 
<template>
  <div>
    <h2>Pets</h2>
    <ul>
      <li v-for="pet in pets" :key="pet.id">
        {{ pet.name }}
      </li>
    </ul>
  </div>
</template>

おわりに

OpenAPIファイルからGoとTypeScriptのコード生成するためのツール・ライブラリの紹介と、それらを使った実装について説明しました。 この導入によって開発効率が良くなり、また今後のAPI開発時に安全に変更が行えるようになりました。

スキーマ駆動開発の一例として、少しでも参考になる点があれば幸いです。