🌟

OpenAPI + Zod で型安全な API クライアント出力

2022/09/24に公開

バックエンドが OpenAPI(REST) で API 仕様を公開している場合、
フロントエンド側で OpenAPI の仕様から、
型安全、かつ API 仕様通りに型定義と API クライアントを生成したい場合があり、
現状 API クライアント生成系ライブラリとして、以下のいずれかが候補に上がるかと思います。

  • openapi-generator
    • 様々な言語に対応した OpenAPI ⇄ 型定義 を生成できる
  • aspida
    • 自前で定義した型から API クライアントを生成したり、OpenAPI から 型定義も含めた API クライアントを生成できる
  • openapi-automatons
    • openapi-generator で痒い所に手が届かないところを解消した OpenAPI → TypeScript(+axios) を生成できる

そこで、他にも良さげなライブラリ無いかなぁと探していたところ、
Zodベースの API クライアント生成ライブラリを見つけたので少し使ってみました。
https://github.com/astahmer/openapi-zod-client

openapi-zod-client

openapi-zod-clientとは、
Zodスキーマを利用した API クライアントを簡単に定義できるZodiosのエコシステムの1つで、
OpenAPI からクライアントと型定義を cli でいい感じに生成できるライブラリです。

サンプル実装

それではサンプルでよくみる Petstoreの定義を利用して、
openapi-zod-client でクライアントを出力して実装してみます。
先にサンプル実装したソースコードは以下にリンクしておきます。
https://github.com/ki504178/nextjs_ts_codebase/pull/19

前提として以下の環境を想定しています。

  • Mac: 12
  • Next.js/TypeScript

install

まずはパッケージをインストールします。
パッケージマネージャーは各々の環境に合わせて置き換えてください。

yarn add @zodios/core
yarn add -D openapi-zod-client

API クライアント出力

以下のコマンドで Petstore の仕様通りに Zodios ベースのクライアントが出力されます。

yarn openapi-zod-client https://petstore3.swagger.io/api/v3/openapi.json \
-o src/zodios/petstore.ts --base-url https://petstore.swagger.io/v3 \
-a

出力ファイルは以下 1 ファイルにまとめられ、
API ごとの型定義と、その型定義を利用した Zodios クライアントが生成されています。

出力ファイル
src/zodios/petstore.ts
import { asApi, Zodios } from '@zodios/core'
import { z } from 'zod'

const vR1x0k5qaLk = z.object({ id: z.number(), name: z.string() }).partial()
const v8JbFEq2fUl = z.object({
  id: z.number().optional(),
  name: z.string(),
  category: vR1x0k5qaLk.optional(),
  photoUrls: z.array(z.string()),
  tags: z.array(vR1x0k5qaLk).optional(),
  status: z.enum(['available', 'pending', 'sold']).optional(),
})
const vlh4E1pXYTG = z.enum(['available', 'pending', 'sold']).optional()
const vh4fxCvnN1b = z.array(v8JbFEq2fUl)
const vGqL1kemtHF = z.array(z.string()).optional()
const vBaxCoPHbgy = z.object({ code: z.number(), type: z.string(), message: z.string() }).partial()
const vLBYC40hXo1 = z
  .object({
    id: z.number(),
    petId: z.number(),
    quantity: z.number(),
    shipDate: z.string(),
    status: z.enum(['placed', 'approved', 'delivered']),
    complete: z.boolean(),
  })
  .partial()
const veNKKR5W6KW = z
  .object({
    id: z.number(),
    username: z.string(),
    firstName: z.string(),
    lastName: z.string(),
    email: z.string(),
    password: z.string(),
    phone: z.string(),
    userStatus: z.number(),
  })
  .partial()
const vVrSPZVa6q7 = z.array(veNKKR5W6KW)

const variables = {
  ApiResponse: vBaxCoPHbgy,
  Order: vLBYC40hXo1,
  Pet: v8JbFEq2fUl,
  User: veNKKR5W6KW,
  addPet: v8JbFEq2fUl,
  addPet_Body: v8JbFEq2fUl,
  createUser: veNKKR5W6KW,
  createUser_Body: veNKKR5W6KW,
  createUsersWithListInput: veNKKR5W6KW,
  createUsersWithListInput_Body: vVrSPZVa6q7,
  findPetsByStatus: vh4fxCvnN1b,
  findPetsByTags: vh4fxCvnN1b,
  getOrderById: vLBYC40hXo1,
  getPetById: v8JbFEq2fUl,
  getUserByName: veNKKR5W6KW,
  placeOrder: vLBYC40hXo1,
  placeOrder_Body: vLBYC40hXo1,
  status: vlh4E1pXYTG,
  tags: vGqL1kemtHF,
  updatePet: v8JbFEq2fUl,
  updatePet_Body: v8JbFEq2fUl,
  updateUser_Body: veNKKR5W6KW,
  uploadFile: vBaxCoPHbgy,
}

const endpoints = asApi([
  {
    method: 'put',
    path: '/pet',
    alias: 'updatePet',
    description: `Update an existing pet by Id`,
    requestFormat: 'json',
    parameters: [
      {
        name: 'body',
        description: `Update an existent pet in the store`,
        type: 'Body',
        schema: variables['updatePet_Body'],
      },
    ],
    response: variables['Pet'],
  },
  {
    method: 'post',
    path: '/pet',
    alias: 'addPet',
    description: `Add a new pet to the store`,
    requestFormat: 'json',
    parameters: [
      {
        name: 'body',
        description: `Create a new pet in the store`,
        type: 'Body',
        schema: variables['addPet_Body'],
      },
    ],
    response: variables['Pet'],
  },
  {
    method: 'get',
    path: '/pet/:petId',
    alias: 'getPetById',
    description: `Returns a single pet`,
    requestFormat: 'json',
    response: variables['Pet'],
  },
  {
    method: 'post',
    path: '/pet/:petId/uploadImage',
    alias: 'uploadFile',
    requestFormat: 'json',
    parameters: [
      {
        name: 'additionalMetadata',
        type: 'Query',
        schema: z.string().optional(),
      },
    ],
    response: variables['ApiResponse'],
  },
  {
    method: 'get',
    path: '/pet/findByStatus',
    alias: 'findPetsByStatus',
    description: `Multiple status values can be provided with comma separated strings`,
    requestFormat: 'json',
    parameters: [
      {
        name: 'status',
        type: 'Query',
        schema: variables['status'],
      },
    ],
    response: z.array(variables['getPetById']),
  },
  {
    method: 'get',
    path: '/pet/findByTags',
    alias: 'findPetsByTags',
    description: `Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.`,
    requestFormat: 'json',
    parameters: [
      {
        name: 'tags',
        type: 'Query',
        schema: variables['tags'],
      },
    ],
    response: z.array(variables['getPetById']),
  },
  {
    method: 'get',
    path: '/store/inventory',
    alias: 'getInventory',
    description: `Returns a map of status codes to quantities`,
    requestFormat: 'json',
    response: z.record(z.number()),
  },
  {
    method: 'post',
    path: '/store/order',
    alias: 'placeOrder',
    description: `Place a new order in the store`,
    requestFormat: 'json',
    parameters: [
      {
        name: 'body',
        type: 'Body',
        schema: variables['placeOrder_Body'],
      },
    ],
    response: variables['Order'],
  },
  {
    method: 'get',
    path: '/store/order/:orderId',
    alias: 'getOrderById',
    description: `For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.`,
    requestFormat: 'json',
    response: variables['Order'],
  },
  {
    method: 'post',
    path: '/user',
    alias: 'createUser',
    description: `This can only be done by the logged in user.`,
    requestFormat: 'json',
    parameters: [
      {
        name: 'body',
        description: `Created user object`,
        type: 'Body',
        schema: variables['createUser_Body'],
      },
    ],
    response: variables['User'],
  },
  {
    method: 'get',
    path: '/user/:username',
    alias: 'getUserByName',
    requestFormat: 'json',
    response: variables['User'],
  },
  {
    method: 'post',
    path: '/user/createWithList',
    alias: 'createUsersWithListInput',
    description: `Creates list of users with given input array`,
    requestFormat: 'json',
    parameters: [
      {
        name: 'body',
        type: 'Body',
        schema: variables['createUsersWithListInput_Body'],
      },
    ],
    response: variables['User'],
  },
  {
    method: 'get',
    path: '/user/login',
    alias: 'loginUser',
    requestFormat: 'json',
    parameters: [
      {
        name: 'username',
        type: 'Query',
        schema: z.string().optional(),
      },
      {
        name: 'password',
        type: 'Query',
        schema: z.string().optional(),
      },
    ],
    response: z.string(),
  },
])

export const api = new Zodios('https://petstore3.swagger.io/api/v3', endpoints)

今回指定したオプションは以下となります。その他のオプションについては公式をご確認ください。

  • -o, --output <path>
    • Zodios API クライアントの出力先を指定します。デフォルトは <input>.ts です。
  • -a, --with-alias
    • API クライアントのメソッド名に、エイリアスメソッドが実装されます。

API クライアントを利用してみる

前段で生成した Zodios クライアントを利用して、
API リクエストを実行してみましょう。
今回のサンプルでは petId をパラメーターとする API を利用し、その内容をテストする形にしています。

src/zodios/api.ts
import { api } from './petstore'

export const getPetById = async (petId: number) => await api.getPetById({ params: { petId: petId } })
src/zodios/api.test.ts
import { getPetById } from './api'

describe('zodios/api', () => {
  test('get', async () => {
    const ret = await getPetById(10)

    expect(ret).toStrictEqual({
      id: 10,
      category: { id: 1, name: 'Dogs' },
      name: 'doggie',
      photoUrls: ['string'],
      tags: [{ id: 0, name: 'string' }],
      status: 'available',
    })
  })
})

無事成功しました!?

まとめ

REST API + OpenAPI でスキーマ駆動のように開発を行う際に、
OpenAPI を正として型定義から API クライアントまでサクッと作成できるため、
aspida と同様に面倒な型定義(+定義ミス)とクライアント実装を省略できるところは有用に感じました。
また、Zodios公式ドキュメントを見る限り、
Node.js 環境であれば、aspida が対応していない FormDatamultipart/form-data, application/x-www-form-urlencoded)にも対応しているようなので、
ライブラリが成熟して実用段階になった場合には、業務でも選択肢の 1 つになり得ると感じました。

Discussion