iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Orvalを使ってOpenAPI SchemaからAPIクライアントを自動生成する

■はじめに

こんにちは!

iimonでエンジニアをしています「しらみず」です。

現在、新規サービス開発をする中で、フロント側とサーバー側のリクエストとレスポンスの型やリクエストパスなどに差が生じないように、どうやってAPIクライアントを生成するかが課題でした。

手動でAPIを叩くコードをフロント側で書くとなると、以下のような課題が発生します。

  • バックエンドのリクエストパスとレスポンスに合わせて、フロントで型定義などを手書きする
  • fetch('/api/todos') のようなfetchラッパーを毎回自分で書く
  • バックエンドのスキーマが変わったとき、フロントの型定義を手動で直す

どれも地味に手間がかかります。さらに「バックエンドの定義と本当に合ってる?」という不安がずっとついてまわります。

この記事では、それを解決するツール Orval を紹介します。

■環境

バックエンド

  • Python >= 3.14
  • Django >= 6.0.4
  • django-ninja >= 1.6.2

フロントエンド

  • React 19.x
  • Vite 8.x
  • TypeScript 6.x
  • Orval 8.x

Pythonのパッケージ管理ツール

  • uv

■Orvalとは何か

Orval は、OpenAPIスキーマからTypeScriptの型定義・APIクライアントを自動生成するライブラリです。

OpenAPIスキーマ(JSON/YAML)
        ↓  Orvalのコマンドを実行すると、APIクライアントを全部自動生成する
・型定義(TypeScript)
・fetchラッパー関数

重要なのは「バックエンド側の定義がSSOT (Single Source of Truth:唯一正しい情報の源泉)になる」というものです。

フロントで型を手書きするのではなく、バックエンドが出力したOpenAPIスキーマを唯一正しい情報の源泉として、そこからフロントのコードを自動生成します。

バックエンドの型が変わったらコマンドを再実行するだけで、フロントの型も追従します。

そのおかげで、最初に挙げた以下の課題を解決することができます。

  • バックエンドのリクエストパスとレスポンスに合わせて、フロントで型定義などを手書きする
  • fetch('/api/todos') のようなfetchラッパーを毎回自分で書く
  • バックエンドのスキーマが変わったとき、フロントの型定義を手動で直す

orval.dev

■Orvalを使うと何が変わるか

◆手書きの場合

// 型定義を手書き
type TodoType = {
  id: number
  title: string
  done: boolean
}

// fetchラッパーを手書き
const getTodos = (): Promise<TodoType[]> =>
  fetch('/api/todos').then((res) => res.json())

Todo一覧を取得するAPIを例に考えてみます。

手書きの場合は、APIが増えるたびに、型定義とfetchラッパーを毎回書くことになります。 バックエンドにフィールドが追加されたら、手動で修正する必要があります。

◆Orvalを使う場合

// 自動生成されたコード(src/api/todos.ts)
export const listTodos = (): Promise<TodoOut[]> =>
  customInstance('/api/todos')

export const getTodo = (todoId: number): Promise<TodoOut> =>
  customInstance(`/api/todos/${todoId}`)

Orvalを使うと、npx orvalというコマンドを実行することで、型定義とfetchラッパーをOpenAPIスキーマから自動生成してくれます。

そのため、コンポーネント側では、Orvalが生成した関数を呼び出すだけで、型補完が効きながら、APIを叩くコードも書かないで済みます。

もし、テーブルが変わって、フィールドが追加されたら、バックエンドでOpenAPIスキーマを更新して、npx orval を再実行するだけで済みます。

■ハンズオン

◆今回作るもの

monorepo/
├── backend/    # Django + Django Ninja(uv)
└── frontend/   # React + Vite + Orval

今回は、モノレポ構成で以下を使います。

  • バックエンド:Django + Django Ninja(uv)
  • フロントエンド:React + Vite + Orval

APIは、以下のようにシンプルにします。

  • GET /api/todos :Todo の一覧を返す
  • GET /api/todos/{id} :Todo を1件返す

◆ディレクトリ構成を作る

mkdir orval-ninja-demo && cd orval-ninja-demo

mkdir backend frontend

まずは、作業ディレクトリを作成します。

モノレポなので、backendとfrontendの2つを同じディレクトリ配下に配置します。

◆バックエンドのセットアップ

cd backend

uv init

uv add django django-ninja

今回は、uvというPythonのパッケージマネージャーを使っています。

一言で言うと「pip + venv + pyenv をまとめて速くしたもの」です。

uvを使うとpipと違って、以下のようなメリットがあります。

  • 仮想環境を作る必要がない
  • 仮想環境を有効化する必要がない
  • Pythonのバージョンやライブラリのバージョンをlockファイルで固定できる

docs.astral.sh

◆Djangoプロジェクトを作成する

uv run django-admin startproject config .

uv run python manage.py startapp api

uvを使って、apiアプリケーションとconfigプロジェクトを作成します。

INSTALLED_APPS = [
     ...
    'ninja',
    'api',
]

backend/config/settings.py'ninja''api'アプリケーションを追加します。

◆モデルを定義する

from django.db import models

class Todo(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)

    def __str__(self):
        return self.title

api/models.py にTodoモデルを定義します。

uv run python manage.py makemigrations

uv run python manage.py migrate

uv run python manage.py showmigrations

マイグレーションを作成・適用します。 showmigrations で適用済みか確認できます。

◆スキーマを ModelSchema で定義する

from ninja import ModelSchema
from .models import Todo

class TodoOut(ModelSchema):
    id: int
    title: str
    done: bool
    
    class Meta:
        model = Todo
        fields = ["id", "title", "done"]

api/schemas.py を作成します。

ModelSchema を使うと、モデルのフィールド定義からスキーマが自動生成されます。

fields に使いたいフィールド名を指定します。

ModelSchema はデフォルト値があるフィールドをoptional、主キーをnullableとして出力するため、レスポンス用スキーマでは必要に応じてフィールドを明示的に上書きしています。

uv run python manage.py shell
from api.models import Todo
Todo.objects.create(title="牛乳を買う", done=False)
Todo.objects.create(title="記事を書く", done=True)
Todo.objects.create(title="Orvalを試す", done=False)
exit()

テーブルにダミーデータを投入しておきます。

◆APIを定義する

from ninja import NinjaAPI
from .models import Todo
from .schemas import TodoOut

api = NinjaAPI()

@api.get("/todos", response=list[TodoOut])
def list_todos(request):
    return Todo.objects.all()

@api.get("/todos/{todo_id}", response=TodoOut)
def get_todo(request, todo_id: int):
    todo = Todo.objects.filter(id=todo_id).first()
    if todo is None:
        return api.create_response(request, {"detail": "Not found"}, status=404)
    return todo

api/views.py にAPIを追加します。

from django.contrib import admin
from django.urls import path
from api.views import api

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', api.urls),
]

プロジェクトのconfig/urls.py にルーティングを追加します。

◆動作確認とスキーマのファイル出力

uv run python manage.py runserver

サーバーを起動します。

http://localhost:8000/api/docsにアクセスすると Swagger UI が表示されます。

uv run python manage.py export_openapi_schema | uv run python -m json.tool > openapi.json

OpenAPI スキーマをファイルに出力します。

backend/openapi.jsonにファイルが出力されます。 OpenAPI スキーマの JSON はhttp://localhost:8000/api/openapi.jsonでも確認できます。

このファイルを Orval が参照して、APIクライアントを生成します。

◆フロントエンドのセットアップ

cd frontend/

npm create vite@latest . -- --template react-ts

npm install

npm install -D orval

フロントエンドのセットアップを行って、Orvalをインストールします。

◆Orvalの設定ファイルを書く

import { defineConfig } from 'orval';

export default defineConfig({
  todos: {
    input: {
      target: '../backend/openapi.json', // バックエンドが出力したOpenAPIスキーマのパス
    },
    output: {
      mode: 'split', // 型定義ファイルとfetch関数ファイルを分けて生成する
      target: 'src/api/todos.ts', // fetch関数の出力先ファイル
      schemas: 'src/api/model', // 型定義の出力先ディレクトリ
      client: 'fetch', // 使用するHTTPクライアント
      clean: true, // 再生成時に古いファイルを自動削除
      override: {
        useTypeOverInterfaces: true,  // interface → type
      },
    },
  },
});

frontend/orval.config.ts を作成します。

input.target にバックエンドで出力したOpenAPIのファイルのパスを指定します。

APIクライアントを生成するには、frontend ディレクトリでnpx orvalを実行します。

src/api/
├── model/
│   ├── todoOut.ts    # 型定義(自動生成)
│   └── index.ts
└── todos.ts          # fetch関数(自動生成)

npx orvalを実行すると、OpenAPIスキーマから、型定義やfetch関数を自動生成してくれます。

自動生成されたfetch関数を使うことで、手動で書くことなくバックエンドとフロントエンドで同期させることができます。

◆モデルのフィールド変更に追従する

class Todo(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    description = models.TextField(blank=True, default="")

api/models.pydescription フィールドを追加します。

uv run python manage.py makemigrations

uv run python manage.py migrate

マイグレーションを作成して、実行します。

class TodoOut(ModelSchema):
    id: int
    title: str
    done: bool
    description: str

    class Meta:
        model = Todo
        fields = ["id", "title", "done", "description"]

api/schemas.py"description" を追加します。

uv run python manage.py export_openapi_schema | uv run python -m json.tool > openapi.jsonでOpenAPIスキーマを再生成します。

npx orvalでフロント側のAPIクライアントを再生成します。

そうすると"description" がクライアント側に追加されていることがわかります。

これがOrvalの良さで、モデルにフィールドを追加するだけで、TypeScriptの型まで自動で追従します。

■まとめ

今回、Orvalを使うことで、以下の課題を解決することができました。

  • バックエンドのリクエストパスとレスポンスに合わせて、フロントで型定義などを手書きする
  • fetch('/api/todos') のようなfetchラッパーを毎回自分で書く
  • バックエンドのスキーマが変わったとき、フロントの型定義を手動で直す

ここから更に、以下の点をCIで組み込んで、差分が出ればCIを落とすようにすることで、フロントとサーバーで噛み合ってない状態でリリースしちゃうことを防ぐことができると思います。

  • uv run python manage.py export_openapi_schema | uv run python -m json.tool > openapi.jsonコマンドを実行して、差分が出たらCIを落とす
  • npx orvalで差分が出たらCIを落とす

また、AI時代においてNotionにドキュメントがあったり、AIに読み込ませるドキュメントがあったりと何が正しいものなのかわからなくなりますが、OpenAPIスキーマをSSOTとして開発していくことは必要不可欠なのかなーと感じています。

ja.wikipedia.org

ここまで読んでくださりありがとうございます!

弊社ではエンジニアを募集しております! 少しでもご興味がありましたら、カジュアル面談でお話ししましょう!

iimon採用サイト / Wantedly