• トップ
  • ブログ一覧
  • 【Next.js】openapi-typescript導入から共通fetcherの実装まで
  • 【Next.js】openapi-typescript導入から共通fetcherの実装まで

    ずお(エンジニア)ずお(エンジニア)
    2025.04.14

    IT技術

    はじめに

    今回は、タイトル通りですが、Next.jsプロジェクトにopenapi-typescriptを導入して共通fetcherを導入するまでを記事にまとめます!
    これまで携わってきたプロジェクトでは、主にGraphQL通信を用いた開発が主流だったこともありまして、REST APIにも触れたいということで実際に触ってみました

    この記事の目的は、openapi-typescriptを用いたフロントエンド側での型生成と共通fetcherの実装によって、型安全な開発体験を得ることにあります。
    そのため、バックエンド側のAPI定義やOpenAPIスキーマの生成方法などについては実装が完了している前提で記事を書いています。
    実際にバックエンドの構築も完了すると、↓のようなAPIドキュメント(Swagger)が生成されます(めちゃ便利)。

    openapi-typescript

    openapi-typescriptは、OpenAPI(Swagger)仕様に基づいたAPI定義ファイルから、TypeScriptの型を自動生成するCLIツールです。
    OpenAPIとは、REST APIの仕様を記述するための標準的なフォーマットであり、主に.yamlや.jsonで記述されます。openapi-typescriptはその定義を読み取り、TypeScriptのインターフェースを生成します。

    導入

    Next.js(TypeScript)で構築されたプロジェクト環境にライブラリをインストールします

    1npm i -D openapi-typescript

    scriptsにコマンドを追加しておくと便利です。
    生成するファイル名や場所なども指定できます。(今回はroot dirにschema.tsというファイル名で追加します)

    1// package.json
    2...
    3  "scripts": {
    4    ...
    5    "generate-schema": "openapi-typescript http://localhost:8000/api-docs/v1/swagger.yaml -o schema.ts",
    6...

    実際にコマンドを叩くと、schema.tsファイルが生成されます。
    今回はお問い合わせAPIのスキーマを確認します。

    各interfaceの役割はこのようなイメージです

    interface役割
    paths各APIエンドポイントの詳細情報(メソッド、リクエスト、レスポンス)を定義
    components.schemas再利用可能な型の定義。各エンドポイントから参照
    1// schema.ts
    2/*
    3 * This file was auto-generated by openapi-typescript.
    4 * Do not make direct changes to the file.
    5 */
    6
    7export interface paths {
    8  '/api/contract_inquiries': {
    9    parameters: {
    10      query?: never
    11      header?: never
    12      path?: never
    13      cookie?: never
    14    }
    15    get?: never
    16    put?: never
    17    /** 問い合わせ */
    18    post: {
    19      parameters: {
    20        query?: never
    21        header?: never
    22        path?: never
    23        cookie?: never
    24      }
    25      requestBody: {
    26        content: {
    27          'application/json': {
    28            contract_inquiry: components['schemas']['RequestContractInquiry']
    29          }
    30        }
    31      }
    32      responses: {
    33        /** @description 問い合わせ成功 */
    34        201: {
    35          headers: {
    36            [name: string]: unknown
    37          }
    38          content: {
    39            'application/json': {
    40              data: {
    41                /** @description 問い合わせを受け付けました */
    42                message: string
    43              }
    44            }
    45          }
    46        }
    47        /** @description 入力値が不正 */
    48        422: {
    49          headers: {
    50            [name: string]: unknown
    51          }
    52          content: {
    53            'application/json': {
    54              /** @example 問い合わせできませんでした: {error message} */
    55              error: string
    56            }
    57          }
    58        }
    59      }
    60    }
    61    delete?: never
    62    options?: never
    63    head?: never
    64    patch?: never
    65    trace?: never
    66  }
    67}
    68export type webhooks = Record
    69export interface components {
    70  schemas: {
    71    RequestContractInquiry: {
    72      /**
    73       * @description74       * @example 山田
    75       */
    76      last_name: string
    77     ...長いので省略
    78    }
    79  }
    80  responses: never
    81  parameters: never
    82  requestBodies: never
    83  headers: never
    84  pathItems: never
    85}
    86export type $defs = Record
    87export type operations = Record

    スキーマヘルパーを実装

    openapi-typescript で生成されるpaths型は非常に詳細ですが、直接使用するには記述が長くなりがちです。API呼び出しごとにリクエスト型やレスポンス型を手動で抽出するのは非効率で、型定義が煩雑になりやすいという課題があります。
    そのため、paths型をベースに、型をより使いやすくするための型ヘルパーを用意します。

    ここでは、各要素(パス、HTTPメソッド、リクエストボディ、レスポンスなど)に対応した型を簡潔に取り出せるような型定義を紹介します。

    生成されたpathsをベースに、共通の型ヘルパーを定義していきます。

    1// src/utils/schemaHelper.ts
    2import { paths } from '~/schema'
    3
    4// API のすべてのパスを列挙する型
    5export type UrlPaths = keyof paths
    6
    7// 利用可能な HTTP メソッドを定義
    8export type HttpMethods = 'get' | 'post' | 'put' | 'patch' | 'delete'
    9
    10/*
    11 * クエリパラメータの型 (存在しない場合は never)
    12 */
    13export type RequestParameters<
    14  Path extends UrlPaths,
    15  Method extends HttpMethods,
    16> = paths[Path][Method] extends { parameters: { query: infer Q } } ? Q : never
    17
    18/**
    19 * リクエストボディの型 (JSON のみ)
    20 * 存在しない場合は never
    21 */
    22export type RequestData<
    23  Path extends UrlPaths,
    24  Method extends HttpMethods,
    25> = paths[Path][Method] extends {
    26  requestBody: { content: { 'application/json': infer JsonType } }
    27}
    28  ? JsonType
    29  : never
    30
    31/**
    32 * ステータスコードごとのレスポンスを mapped type で取り出し、成功・エラーに分ける
    33 */
    34type ResponseMap<
    35  Path extends UrlPaths,
    36  Method extends HttpMethods,
    37> = paths[Path][Method] extends { responses: infer R }
    38  ? {
    39      [StatusCode in keyof R & number]: StatusCode extends 200 | 201
    40        ? {
    41            status: 'success'
    42            code: StatusCode
    43            data: R[StatusCode] extends {
    44              content: { 'application/json': infer JSONType }
    45            }
    46              ? JSONType
    47              : never
    48          }
    49        : {
    50            status: 'error'
    51            code: StatusCode
    52            message: R[StatusCode] extends {
    53              content: { 'application/json': { error: infer E } }
    54            }
    55              ? E extends string
    56                ? E
    57                : string
    58              : string
    59          }
    60    }[keyof R & number]
    61  : never
    62
    63/**
    64 * 最終的に返るレスポンス型
    65 */
    66export type ResponseData<
    67  Path extends UrlPaths,
    68  Method extends HttpMethods,
    69> = ResponseMap<Path, Method>

    共通fetcherの実装

    スキーマヘルパーを活用して、汎用的に使用するfetcherを作成します。

    1// src/utils/fetcher.ts
    2import {
    3  HttpMethods,
    4  RequestData,
    5  RequestParameters,
    6  ResponseData,
    7  UrlPaths,
    8} from './schemaHelper'
    9
    10interface FetcherOptions<Path extends UrlPaths, Method extends HttpMethods> {
    11  /* fetch APIのオプション */
    12  cache?: RequestInit['cache']
    13  next?: {
    14    revalidate: false | number
    15    tags: string[]
    16  }
    17  parameters?: RequestParameters<Path, Method>
    18  data?: RequestData<Path, Method> | FormData
    19  headers?: Record<string, string>
    20}
    21
    22const API_HOST = process.env.NEXT_PUBLIC_API_URL ?? ''
    23
    24/**
    25 * 共通の実装
    26 * → "Path" と "Method" の対応関係を厳密には縛らず、Method は 'get'|'post'|...' のどれでもOK にしている
    27 */
    28async function baseFetcher<Path extends UrlPaths, Method extends HttpMethods>(
    29  path: Path,
    30  method: Method,
    31  options: FetcherOptions<Path, Method> = {},
    32): Promise<ResponseData<Path, Method>> {
    33  const { parameters, data, headers, ...rest } = options
    34  const fetchOptions: RequestInit = {
    35    ...rest,
    36    method: method.toUpperCase(),
    37    headers: {
    38      'Content-Type': 'application/json',
    39      ...headers,
    40    },
    41  }
    42
    43  if (data) fetchOptions.body = JSON.stringify(data)
    44
    45  let queryString = ''
    46  if (parameters) {
    47    queryString =
    48      '?' + new URLSearchParams(parameters as Record<string, string>).toString()
    49  }
    50
    51  try {
    52    const response = await fetch(
    53      `${API_HOST}${path}${queryString}`,
    54      fetchOptions,
    55    )
    56    const statusCode = response.status
    57
    58    let responseBody: unknown = {}
    59    try {
    60      responseBody = await response.json()
    61    } catch (error) {
    62      console.error('[API Fetch Error - JSON parse]: ', path, error)
    63      responseBody = {}
    64    }
    65
    66    if (statusCode >= 200 && statusCode < 300) {
    67      // 2xx
    68      return {
    69        status: 'success',
    70        code: statusCode,
    71        data: responseBody,
    72      } as ResponseData<Path, Method>
    73    } else {
    74      // 4xx or 5xx
    75      const errorMessage =
    76        typeof responseBody === 'object' &&
    77        responseBody !== null &&
    78        'error' in responseBody
    79          ? (responseBody as { error?: string }).error || 'API Error'
    80          : 'API Error'
    81
    82      return {
    83        status: 'error',
    84        code: statusCode,
    85        message: errorMessage,
    86      } as ResponseData<Path, Method>
    87    }
    88  } catch (error) {
    89    console.error('[API Fetch Error]: ', path, error)
    90    return {
    91      status: 'error',
    92      code: 500,
    93      message: 'Network Error',
    94    } as ResponseData<Path, Method>
    95  }
    96}

    メソッドのシンプル化

    最後にbaseFetcherを元に、apiメソッドをシンプル化します

    1export function apiGet<Path extends UrlPaths>(
    2  path: Path,
    3  options?: FetcherOptions<Path, 'get'>,
    4): Promise<ResponseData<Path, 'get'>> {
    5  return baseFetcher(path, 'get', options)
    6}
    7
    8...put, patchなども同様に

    実際に使用してみる

    お問い合わせのapi fetchを実装します。

    1import { API } from '~/src/constants/api'
    2import { apiPost } from '~/src/utils/api/fetcher'
    3import { RequestData } from '~/src/utils/api/schemaHelper'
    4
    5export const sendInquiry = (
    6  data: RequestData<typeof API.CONTRACT_INQUIRIES, 'post'>,
    7) => {
    8  return apiPost<typeof API.CONTRACT_INQUIRIES>(API.CONTRACT_INQUIRIES, {
    9    data,
    10  })
    11}

    上記ファイルをフォーム送信時に呼びます

    1...
    2    try {
    3      const res = await sendInquiry(sendApiData)
    4
    5      if (res.status === 'error') {
    6        toast.error(res.message)
    7      } else {
    8        router.push(PATH.INQUIRY_COMPLETE)
    9      }
    10    } catch (e) {
    11...

    responseの型も意図したものになっていることを確認。パチパチ👏

    1const res: {
    2    status: "success";
    3    code: 201;
    4    data: {
    5        data: {
    6            message: string;
    7        };
    8    };
    9} | {
    10    status: "error";
    11    code: 422;
    12    message: string;
    13}

    終わりに

    実際に使用してみて、個人的には使いやすかったと感じています。
    JSやTSで一般的に使用される命名規則は、キャメルケースだと思いますが、APIレスポンスに関してもリネームできればと考えていたのですが、公式ドキュメントを拝見したところ、リネームは避けてくださいとのことでした、、

    「一貫性」をより包括的な概念と見なし、JSスタイルの規約に従うよりもAPIスキーマをそのまま保持することが優れていると認識してください。

    今回の実装についても、より柔軟で堅牢な構成を目指す上では、まだまだ改善の余地がたくさんあります。
    コツコツと最適解を探りながら、よりよい開発体験を目指してアップデートしていきたいと思います。

    最後までお付き合いいただきありがとうございました🙇‍♂️

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    ずお(エンジニア)
    ずお(エンジニア)
    Show more...

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background