OpenAPI + Zod で型安全な API クライアント出力
バックエンドが OpenAPI(REST) で API 仕様を公開している場合、
フロントエンド側で OpenAPI の仕様から、
型安全、かつ API 仕様通りに型定義と API クライアントを生成したい場合があり、
現状 API クライアント生成系ライブラリとして、以下のいずれかが候補に上がるかと思います。
-
openapi-generator
- 様々な言語に対応した OpenAPI ⇄ 型定義 を生成できる
-
aspida
- 自前で定義した型から API クライアントを生成したり、OpenAPI から 型定義も含めた API クライアントを生成できる
-
openapi-automatons
-
openapi-generator
で痒い所に手が届かないところを解消した OpenAPI → TypeScript(+axios) を生成できる
-
そこで、他にも良さげなライブラリ無いかなぁと探していたところ、
Zodベースの API クライアント生成ライブラリを見つけたので少し使ってみました。
openapi-zod-client
openapi-zod-clientとは、
Zodスキーマを利用した API クライアントを簡単に定義できるZodiosのエコシステムの1つで、
OpenAPI からクライアントと型定義を cli でいい感じに生成できるライブラリです。
サンプル実装
それではサンプルでよくみる Petstoreの定義を利用して、
openapi-zod-client
でクライアントを出力して実装してみます。
先にサンプル実装したソースコードは以下にリンクしておきます。
前提として以下の環境を想定しています。
- 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 クライアントが生成されています。
出力ファイル
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
です。
- Zodios API クライアントの出力先を指定します。デフォルトは
-
-a, --with-alias
- API クライアントのメソッド名に、エイリアスメソッドが実装されます。
API クライアントを利用してみる
前段で生成した Zodios クライアントを利用して、
API リクエストを実行してみましょう。
今回のサンプルでは petId
をパラメーターとする API を利用し、その内容をテストする形にしています。
import { api } from './petstore'
export const getPetById = async (petId: number) => await api.getPetById({ params: { petId: petId } })
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
が対応していない FormData
(multipart/form-data
, application/x-www-form-urlencoded
)にも対応しているようなので、
ライブラリが成熟して実用段階になった場合には、業務でも選択肢の 1 つになり得ると感じました。
Discussion