🐮

aspidaでOpenAPIからクライアント用の型とコードを自動生成する

2023/03/08に公開

OpenAPIのスキーマからクライアント用の型とコードを自動生成する方法について、以下のサイトのまとめが参考になりました。

https://tech.mobilefactory.jp/entry/2021/12/10/000000

aspida

その中から、 aspida というライブラリが良さそうだったので試してみました。

aspidaは日本人が作成した、TypeScriptに特化したツールで、作者の方の記事を参考にさせていただきました。

https://zenn.dev/solufa/articles/getting-started-with-aspida

記事の趣旨

openapi2aspida + @aspida/axios を使って、簡単に試してみます。

後述しますが、2023/03/08時点で一番大事なのは、axiosのバージョンをv0系を使うことです

手順

OpanAPIスキーマを準備する

サンプルとして、Bookの取得と作成のAPIを用意しました。

Bookの定義

openapi.yml
openapi: 3.0.3
info:
  title: Sample API
  version: "1.0"
servers:
  - url: "http://localhost:3000"
paths:
  /books/{id}:
    parameters:
      - schema:
          type: string
        name: id
        in: path
        required: true
    get:
      operationId: get-book
      responses:
        "200":
          description: Book
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Book"
              examples:
                Example 1:
                  value:
                    id: 1
                    status: published
                    name: Sample Book1
                    created_at: "2019-08-24T14:15:22Z"
        "404":
          description: User Not Found
  /books:
    post:
      operationId: post-book
      responses:
        "200":
          description: Book Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Book"
              examples:
                Example 1:
                  value:
                    id: 1
                    status: published
                    name: Sample Book1
                    created_at: "2019-08-24T14:15:22Z"
        "400":
          description: Missing Required Information
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name:
                  type: string
                status:
                  type: string
                  enum:
                    - published
                    - draft
              required:
                - name
                - status
            examples:
              Example 1:
                value:
                  name: Sample Book1
                  status: published
components:
  schemas:
    Book:
      type: object
      additionalProperties: false
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          enum:
            - published
            - draft
        created_at:
          type: string
          format: date-time
      required:
        - id
        - name
        - status
        - created_at

型、コードを自動生成する

プロジェクトルートに上記の openapi.yml を置いて、以下を実行します。
(openapi2aspidaはローカルにインストールしなくても良い)

npx openapi2aspida -i=openapi.yaml

作成されるファイル

これで api 配下に以下のようなフォルダが作成されます。

api
├── $api.ts
├── @types
│   └── index.ts
└── books
    ├── $api.ts
    ├── _id@string
    │   └── index.ts
    └── index.ts

@types/index.ts

Bookの型。statusのenumも正しく定義されています。

/* eslint-disable */
export type Book = {
  id: number
  name: string
  status: 'published' | 'draft'
  created_at: string
}

books/index.ts

Book作成APIの型定義

/* eslint-disable */
import type * as Types from '../@types'

export type Methods = {
  post: {
    status: 200
    /** Book Created */
    resBody: Types.Book

    reqBody: {
      name: string
      status: 'published' | 'draft'
    }
  }
}

books/_id@string/index.ts

Book取得APIの型定義

/* eslint-disable */
import type * as Types from '../../@types'

export type Methods = {
  get: {
    status: 200
    /** Book */
    resBody: Types.Book
  }
}

$api.ts, books/$api.ts

apiリクエスト用のコード

開く
$api.ts
import type { AspidaClient, BasicHeaders } from 'aspida'
import type { Methods as Methods0 } from './books'
import type { Methods as Methods1 } from './books/_id@string'

const api = <T>({ baseURL, fetch }: AspidaClient<T>) => {
  const prefix = (baseURL === undefined ? 'http://localhost:3000' : baseURL).replace(/\/$/, '')
  const PATH0 = '/books'
  const GET = 'GET'
  const POST = 'POST'

  return {
    books: {
      _id: (val1: string) => {
        const prefix1 = `${PATH0}/${val1}`

        return {
          /**
           * @returns Book
           */
          get: (option?: { config?: T | undefined } | undefined) =>
            fetch<Methods1['get']['resBody'], BasicHeaders, Methods1['get']['status']>(prefix, prefix1, GET, option).json(),
          /**
           * @returns Book
           */
          $get: (option?: { config?: T | undefined } | undefined) =>
            fetch<Methods1['get']['resBody'], BasicHeaders, Methods1['get']['status']>(prefix, prefix1, GET, option).json().then(r => r.body),
          $path: () => `${prefix}${prefix1}`
        }
      },
      /**
       * @returns Book Created
       */
      post: (option: { body: Methods0['post']['reqBody'], config?: T | undefined }) =>
        fetch<Methods0['post']['resBody'], BasicHeaders, Methods0['post']['status']>(prefix, PATH0, POST, option).json(),
      /**
       * @returns Book Created
       */
      $post: (option: { body: Methods0['post']['reqBody'], config?: T | undefined }) =>
        fetch<Methods0['post']['resBody'], BasicHeaders, Methods0['post']['status']>(prefix, PATH0, POST, option).json().then(r => r.body),
      $path: () => `${prefix}${PATH0}`
    }
  }
}

export type ApiInstance = ReturnType<typeof api>
export default api

books/$api.ts
import type { AspidaClient, BasicHeaders } from 'aspida'
import type { Methods as Methods0 } from '.'
import type { Methods as Methods1 } from './_id@string'

const api = <T>({ baseURL, fetch }: AspidaClient<T>) => {
  const prefix = (baseURL === undefined ? 'http://localhost:3000' : baseURL).replace(/\/$/, '')
  const PATH0 = '/books'
  const GET = 'GET'
  const POST = 'POST'

  return {
    _id: (val0: string) => {
      const prefix0 = `${PATH0}/${val0}`

      return {
        /**
         * @returns Book
         */
        get: (option?: { config?: T | undefined } | undefined) =>
          fetch<Methods1['get']['resBody'], BasicHeaders, Methods1['get']['status']>(prefix, prefix0, GET, option).json(),
        /**
         * @returns Book
         */
        $get: (option?: { config?: T | undefined } | undefined) =>
          fetch<Methods1['get']['resBody'], BasicHeaders, Methods1['get']['status']>(prefix, prefix0, GET, option).json().then(r => r.body),
        $path: () => `${prefix}${prefix0}`
      }
    },
    /**
     * @returns Book Created
     */
    post: (option: { body: Methods0['post']['reqBody'], config?: T | undefined }) =>
      fetch<Methods0['post']['resBody'], BasicHeaders, Methods0['post']['status']>(prefix, PATH0, POST, option).json(),
    /**
     * @returns Book Created
     */
    $post: (option: { body: Methods0['post']['reqBody'], config?: T | undefined }) =>
      fetch<Methods0['post']['resBody'], BasicHeaders, Methods0['post']['status']>(prefix, PATH0, POST, option).json().then(r => r.body),
    $path: () => `${prefix}${PATH0}`
  }
}

export type ApiInstance = ReturnType<typeof api>
export default api

リクエストする

パッケージ追加

ReactやVueなどのプロジェクトがある前提で、以下のパッケージを追加します。

npm i @aspida/axios axios@^0.27.2

リクエストする

import axiosClient from '@aspida/axios'
import axios from 'axios';
import api from "./api/$api"

// APIクライアントの設定
const axiosConfig = { baseURL: "http://localhost:3000" };
const client = api(aspida(axios, axiosConfig));

// id1のBookを取得する
client.books._id("1").$get();
// => { id: 1, status: 'published', name: 'Sample Book1', created_at: '2023-03-08T06:05:38' }

// Bookを作成する
client.books.$post({ body: { name: "Sample", status: "published" })
// => { id: 1, status: 'published', name: 'Sample', created_at: '2023-03-08T06:05:38' }

感想

API修正時に、OpenAPIとサーバー側を修正するだけでクライアント側のコードが自動修正されるのはとても便利そうです。

Discussion