 ずお(エンジニア)
ずお(エンジニア)【Next.js】openapi-typescript導入から共通fetcherの実装まで
 ずお(エンジニア)
ずお(エンジニア)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-typescriptscriptsにコマンドを追加しておくと便利です。
生成するファイル名や場所なども指定できます。(今回は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       * @description 姓
74       * @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スキーマをそのまま保持することが優れていると認識してください。
今回の実装についても、より柔軟で堅牢な構成を目指す上では、まだまだ改善の余地がたくさんあります。
コツコツと最適解を探りながら、よりよい開発体験を目指してアップデートしていきたいと思います。
最後までお付き合いいただきありがとうございました🙇♂️
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ

愛媛県の田舎町で生まれ育ちましたずおと申します。 趣味はサウナと居酒屋巡りです。 未経験で入社させていただいたので、いち早く戦力になれるように日々頑張ります! 座右の銘は「悩んでるひまに、一つでもやりなよ」 ドラえもんの名言です。よろしくお願いします。












