見出し画像

OpenAPIでコードを生成してスキーマ駆動で開発する:GoとTypeScriptの場合

こんにちは。走る鳥です。ナビタイムジャパンでSREを担当しています。

新規でWebサービスを構築した際に、OpenAPIの仕様書をもとにコードを生成することで、APIとクライアントの開発効率をアップしました。バックエンドはGo、フロントエンドはTypeScriptで開発しましたので、これらの言語向けのコード生成の手法について紹介したいと思います。

OpenAPIについての説明や、仕様書の編集・閲覧に便利なツールについては、こちらの記事で紹介していますので、併せてご参照ください。

Stoplight Studioを使ってREST APIのスキーマ駆動開発に対するハードルを下げる|NAVITIME_Tech|note

開発言語・環境

  • バックエンド

    • Go 1.18

    • aws-lambda-go v1.32.0

    • AWS SAM (API Gateway + Lambda)

    • oapi-codegen v1.11.0

  • フロントエンド

    • TypeScript 4.6

    • Nuxt.js 2.16

    • axios v0.21.4

    • OpenAPI Generator 5.4.0

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

導入の背景

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

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

導入した結果

導入前
導入後
  1. 仕様書とコードが一致するようになった

  2. それぞれの言語向けにAPIのコードを実装するコストが減った

  3. フロントエンドとバックエンドの実装が並行して進められるようになった

1については、APIを変更したときに追従漏れがなくなったのが嬉しいポイントでした。
また、コードを再生成するとGoやTypeScriptでAPIを利用している箇所でコンパイルエラーがでるので、変更が安全に行えるようになりました。

2については、導入前はGoのAPIリクエスト、レスポンスの構造体や、TypeScriptのAPIクライアントのコードを手で実装していて数分〜数十分かかっており、同じようなコードを2つの言語で書くことのストレスもありました。
これがコマンドひとつで数秒で行えるようになり、ビジネスロジックの実装により注力できるようになりました。

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 を使用しました。

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 型の変数にパースしています。
このライブラリは、mapのキー名と同名のフィールドに値をセットすることができます。

次のように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 としないといけません。

mapstructureは、 `mapstructre:"sort-by"` のようなstruct tagを書くことで、任意のフィールドに適用することもできます。
oapi-codegen では、x-oapi-codegen-extra-tags を書くことで任意のstruct tagをつけることができるようになっていますので、この機能を使って、 mapstructure用の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 が期待通り働くようになりました。

この任意のtagをつける機能を使えば、他にも go-playground/validator 用のtagを追加するなども可能です。

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

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

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

package.json

{
  "scripts": {
    "openapi-generate": "rm -f api_client/*.ts && 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

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

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開発時に安全に変更が行えるようになりました。

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