EY-Office ブログ

FastAPIは良いAPIサーバー用フレームワークですね!

前回は、Pythonの軽量WebフレームワークFlaskに付いて書きましたが、Django以外のPythonのWebフレームワークを調べたさいに出てきたFastAPIも気になったので、前回のコードをFastAPI + Reactで書いてみました。

FastAPI Gemini AI image generatorが生成した画像を使っています

FastAPIとは

FastAPIはAPIサーバーを開発するためのフレームワークです。主な特徴はホームページに書かれているように、

  • 高速 : NodeJS や Go 並みのとても高いパフォーマンス (Starlette と Pydantic のおかげです)。 最も高速な Python フレームワークの一つです
  • 高速なコーディング: 開発速度を約 200%~300%向上させます
  • 少ないバグ: 開発者起因のヒューマンエラーを約 40%削減します
  • 直感的: 素晴らしいエディタのサポートや オートコンプリート。 デバッグ時間を削減します
  • 簡単: 簡単に利用、習得できるようにデザインされています。ドキュメントを読む時間を削減します
  • 短い: コードの重複を最小限にしています。各パラメータからの複数の機能。少ないバグ
  • 堅牢性: 自動対話ドキュメントを使用して、本番環境で使用できるコードを取得します
  • Standards-based: API のオープンスタンダードに基づいており、完全に互換性があります: OpenAPI (以前は Swagger として知られていました) や JSON スキーマ

ホームページのアプリケーション例を試したさいに感じたのは、シンプルな記述でAPIサーバーが構築でき。しかも、自動対話型の API ドキュメント(Swagger UI)で動作確認できます。これは良さそう!😃

Pydantic

APIサーバーは、一般にリクエストを受け付けてデータベースのレコードをJSON形式で戻したり、リクエストで受け取ったJSON形式のデータをレコードに変更しデータベースに格納したりします。
APIサーバーを作るのに面倒な点の1つは、受け取ったデータをチェックするコードを作る必要があることです。FastAPIではPydanticというバリデーションライブラリーを使っています。 AI(Perplexy)にまとめてもらった特徴は、

  • 型のバリデーションと型変換の自動化
    モデルに型ヒントを記述するだけで、入力データの型や値の検証、必要に応じた型変換(例:文字列からintやdatetime型への変換)が自動で行われます
  • シンプルで直感的なモデル定義
    BaseModelを継承したクラスでデータ構造を宣言的に定義でき、複雑なネスト構造や継承も簡単に表現できます
  • 詳細で分かりやすいエラーメッセージ
    バリデーションエラーが発生した場合、どのフィールドで何が問題だったかを明確に示すエラーメッセージが返されます
  • 型安全性の向上
    型不一致によるバグやエラーを早期に検出でき、堅牢なコードを書くことができます
  • データのシリアライゼーション/デシリアライゼーション
    PythonオブジェクトとJSONや辞書形式の相互変換が容易です
  • カスタマイズ性と拡張性
    独自のバリデーションロジックやフィールドレベルの制約(長さ、範囲など)も柔軟に追加できます
  • FastAPIとの高い親和性
    FastAPIではリクエストやレスポンスの型としてPydanticモデルを利用でき、APIドキュメントの自動生成もサポートされています
  • 高速な動作
    Pydantic v2以降はコアロジックがRustで実装されており、パフォーマンスも高いです
  • 幅広い用途
    APIのリクエスト・レスポンス検証、設定ファイルの管理、データベースとのデータ整形など、さまざまな場面で利用されています

Pydanticはコードがコンパクトに書けて、判りやすい、使いやすいライブラリーです。

APIサーバー

FastAPIを使ったAPIサーバーのコードは以下のようになりました。コメントを入れたのでコメントを読んでもらうと他のフレームワークでAPIサーバーを作った事がある方はコードが読めるかと思います。コメントはAIに書いてもらいました。(一部は私が修正しましたが)😃

  • データベースは前回と同じくsqlite3を使っています
  • jp_errorsは、エラーメッセージを日本語に変換するモジュールです。後で説明します
  • APIサーバーはポート8000、Reactはポート5173で動いているのでCORS対応のミドルウエアを入れています
  • PydanticはBaseModelを継承したクラスでモデルを定義します。Pythonの型ヒント(型アノテーション)でプロパティ(フィールド)を定義します
    • List, Optional等はPythonのtypingモジュールに定義されています
    • @field_validatorでフィールドに独自の処理を組み込めます、ここでは入力が空文字なNoneを戻します
    • @model_validatorはモデル単位での独自処理(独自バリデーション)を組み込めます、ここでは入金・出金の両方が指定されてない、両方が指定されている場合にエラーにしています
from fastapi import Depends, FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, List
from datetime import date
import sqlite3
import jp_errors

# FastAPIのインスタンスを作成し、jp_errors.get_localeを依存関係として追加
app = FastAPI(dependencies=[Depends(jp_errors.get_locale)])
# RequestValidationErrorを処理するための例外ハンドラーを追加
app.add_exception_handler(
    RequestValidationError, jp_errors.validation_exception_handler
)
# オリジン間リソース共有 (CORS) middlewareを追加
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "http://localhost:3000"],
    allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
)

# SQLiteデータベースの接続を作成
#  check_same_thread=Falseでスレッド間で同じ接続を共有する
conn = sqlite3.connect('db/allowance_book.db', check_same_thread=False)
conn.row_factory = sqlite3.Row   # 行を辞書形式で取得
cursor = conn.cursor()

# 小遣い帳フォームのモデル定義
class AllowanceBook(BaseModel):
  date: date
  description: str = Field(..., min_length=1, max_length=255)
  money_in: Optional[int]
  money_out: Optional[int]

  @field_validator('money_in', mode='before')  # 入金が空文字列の場合はNoneに変換
  def money_in_empty_str_to_none(cls, v):
    return None if v  == '' else v

  @field_validator('money_out', mode='before')  # 出金が空文字列の場合はNoneに変換
  def money_out_empty_str_to_none(cls, v):
    return None if v  == '' else v

  # 入金と出金の両方が指定されてない、または両方が指定されている場合はエラーにする
  @model_validator(mode='after')
  def check_not_both_none(cls, values):
    if values.money_in is None and values.money_out is None:
      raise ValueError('入金または出金を入力してください')
    if values.money_in is not None and values.money_out is not None:
      raise ValueError('入金と出金の両方を入力しないでください')
    return values

# 小遣い帳のレコードのモデル定義
class AllowanceBookRecord(AllowanceBook):
    id: int
    balance: int

# GET /allowancesの処理
#   小遣い帳の全データを戻す
@app.get("/allowances", response_model=List[AllowanceBookRecord])
def get_all_allowances():
  cursor.execute('''
    SELECT id, date, description, money_in, money_out,
    SUM(COALESCE(money_in, 0) - COALESCE(money_out, 0)) OVER (ORDER BY date, id) AS balance
    FROM allowance_book
  ''')
  allowances = cursor.fetchall()
  return [AllowanceBookRecord(**allowance) for allowance in allowances]

# POST /allowancesの処理
#   小遣い帳のデータを追加する
@app.post("/allowances")
def create_allowance(allowance: AllowanceBook):
  cursor.execute('''
    INSERT INTO allowance_book (date, description, money_in, money_out)
    VALUES (?, ?, ?, ?)
  ''', (allowance.date, allowance.description, allowance.money_in, allowance.money_out))
  conn.commit()
  return {"detail": [], "message": "Allowance created successfully"}

# DELETE /allowancesの処理
#   allowance_idで指定された、小遣い帳のデータを削除する
@app.delete("/allowances/{allowance_id}")
def delete_allowance(allowance_id: int):
  cursor.execute('''
    DELETE FROM allowance_book WHERE id = ?
  ''', (allowance_id,))
  conn.commit()
  return {"detail": [], "message": "Allowance deleted successfully"}

# python server.pyで実行された際に行う処理をこのif文内に書きます
if __name__ == '__main__':
 # もしallowance_bookテーブルが無ければCREATE TABLEでallowance_bookテーブルを作成します
 cursor.execute('''
  CREATE TABLE IF NOT EXISTS allowance_book (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    date DATE,
    description VARCHAR(255),
    money_in INTEGER,
    money_out INTEGER
  )
  ''')
実行

サーバーの起動は以下のようにします、UvicornASGIプロトコルに対応したアプリ実行するサーバーです。
--reloadオプションは、アプリのコードが変更されると自動的にリロードする開発用オプションですね。

$ uvicorn main:app --reload
jp_errors.py

エラーメッセージを日本語化するモジュールで、pydantic用i18n拡張pydantic-i18nを使っています。ほぼGitHubに書いてあるコードです。

  • 日本語エラーメッセージの辞書は"ja_JP"に定義します
  • しかし、“en_US”にキーを用意しないと英語メッセージが使われてしまうので必要なようです 😅
  • # Remove non-JSON serializable 'ctx' field from each error ...以下のコードはAI(Google Gemini 2.5 Flash Preview)に教えてもらったエラー対応コードです
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from starlette.responses import JSONResponse
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
from pydantic_i18n import PydanticI18n

__all__ = ["get_locale", "validation_exception_handler"]

DEFAULT_LOCALE = "ja_JP"

translations = {
    "en_US": {
        "Input should be a valid date or datetime, {}": "",
        "String should have at least {} character": "",
        "Input should be a valid integer, unable to parse string as an integer": "",
        "Value error, {}": "",
    },
    "ja_JP": {
        "Input should be a valid date or datetime, {}": "日付を入力してください",
        "String should have at least {} character": "{}文字以上入力してください",
        "Input should be a valid integer, unable to parse string as an integer":
          "数値を入力してください",
        "Value error, {}": "{}",
    },
}

tr = PydanticI18n(translations)

def get_locale(locale: str = DEFAULT_LOCALE) -> str:
  return locale

async def validation_exception_handler(
  request: Request, exc: RequestValidationError
) -> JSONResponse:
  current_locale = request.query_params.get("locale", DEFAULT_LOCALE)

  # Translate errors
  translated_errors = tr.translate(exc.errors(), locale="ja_JP")

  # Remove non-JSON serializable 'ctx' field from each error By Google Gemini 2.5 Flash Preview
  cleaned_errors = []
  for error in translated_errors:
    if isinstance(error, dict):
      cleaned_error = {k: v for k, v in error.items() if k != 'ctx'}
      cleaned_errors.append(cleaned_error)
    else:
      cleaned_errors.append(error) # Keep non-dict items as is

  return JSONResponse(
    status_code=HTTP_422_UNPROCESSABLE_ENTITY,
    content={"detail": cleaned_errors},
  )

まとめ

今回、FastAPIを使ってAPIサーバーを作ってみましたが、印象は非常に良かったです。ただし、Pydanticには少し苦労しました。Pythonの型ヒントやデコレーターを使っていて、初見では凝縮されたコードに戸惑いました。

しかし、AIに助けてもらえる機会が多かったです。新しいライブラリやプログラミング言語を使う際にはAIの支援はとても有用ですね。ただし、教えてもらったコードを単純にコピーするだけでなく、自分で試行錯誤することでライブラリ等の理解を深めることも重要だと思いました。

FastAPI自体は特に難しくなく、他の言語でAPIサーバーを作ったことがある方ならすぐに使えるのではないかと思います。また今回は同期処理のsqlite3を使ってしまいましたが、FastAPIはリクエストの非同期処理(async/await)が使えるため、サーバーの処理能力を高めるのに役立つのではと思います。

さらに、下の画像のようにhttp://localhost:8000/docsでアクセスするとSwaggerツールが動作し、クライアントソフトがなくてもすぐにサーバーの動作を確認できるのも良いですね。

クライアントのReactコードは通常のReactアプリなので最後にコードのみ上げておきます(画面は先週のアプリと同じになります)。

参照:Reactコード

  • 開発環境はViteを使っています
  • TypeScriptを使っています
  • スタイリングはTailwind CSSを使っています
  • 入力フォームの処理はReact19で導入されたuseActionStateを使っています
  • 画面の再表示が必要な場合はuseReloadカスタムフックを自作し使っています
ファイル構成
.
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
│   └── vite.svg
├── README.md
├── src
│   ├── assets
│   │   └── react.svg
│   ├── components
│   │   ├── App.tsx
│   │   ├── Entry.tsx
│   │   └── List.tsx
│   ├── index.css
│   ├── libs
│   │   ├── backendAPI.ts
│   │   └── useReload.ts
│   ├── main.tsx
│   └── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
App.tsx
import List from "./List";
import Entry from "./Entry";
import { useReload } from "../libs/useReload";
import { useEffect, useState } from "react";
import { deleteAllowance, fetchAllowances, InitAllowanceEntryState, 
  postAllowance, type AllowanceEntryState, type AllowanceEntryPost, 
  type AllowanceRecord, makeErrorStrings } from "../libs/backendAPI";

// 小遣い帳のメイン・コンポーネント
export default function App() {
  const [reload, forceReload] = useReload();
  const [allowances, setAllowances] = useState<AllowanceRecord[]>([]);

  // レコードの削除アクション
  const deleteRecord = async (id: number) => {
    await deleteAllowance(id);
    forceReload();
  };

  // 追加フォームの送信アクション
  const formAction = async (_previousState: AllowanceEntryState,
    formData: FormData): Promise<AllowanceEntryState> => {

    const data = {
      date: String(formData.get("date")),
      description: String(formData.get("description")),
      money_in: String(formData.get("money_in")),
      money_out: String(formData.get("money_out")),
    };
    const result = await postAllowance(data);

    if (result.detail.length == 0) {
      forceReload();
      return InitAllowanceEntryState;
    } else {
      return {...data, errors: makeErrorStrings(result.detail)};
    }
  }

  // 初期データの取得
  useEffect(() => {
    (async () => {
      const data = await fetchAllowances();
      setAllowances(data);
    })();
  }, [reload]);

  return (
    <div className="m-6 w-1/2">
      <h1 className="text-3xl font-bold">小遣い帳</h1>
      <List allowances={allowances} deleteRecord={deleteRecord} />
      <Entry formAction={formAction} />
    </div>
  )
}
List.tsx
// 小遣い帳のリストを表示するコンポーネント

import { type AllowanceRecord } from "../libs/backendAPI";

type ListProps = {
  allowances: AllowanceRecord[];
  deleteRecord: (id: number) => Promise<void>;
}
export default function List({allowances, deleteRecord}: ListProps) {

  if (allowances.length === 0) {
    return <div>Loading...</div>;
  }
  return (
      <table className="mt-4 min-w-full divide-y divide-gray-200 shadow-sm rounded-lg overflow-hidden">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日付</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内容</th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">入金</th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">出金</th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">残高</th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          {allowances.map((allowance) => (
            <tr key={allowance.id}>
              <td className="px-6 py-4 whitespace-nowrap">{ allowance.date }</td>
              <td className="px-6 py-4 whitespace-nowrap">{ allowance.description }</td>
              <td className="px-6 py-4 whitespace-nowrap text-right">{ allowance.money_in }</td>
              <td className="px-6 py-4 whitespace-nowrap text-right">{ allowance.money_out }</td>
              <td className="px-6 py-4 whitespace-nowrap text-right">{ allowance.balance }</td>
              <td>
                <button onClick={async () => {await deleteRecord(allowance.id);}}
                type="submit" className="px-2 py-1 text-xs font-medium text-center text-white bg-red-700 rounded hover:bg-red-800">削除</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
  );
}
Entry.tsx
// 小遣い帳の入力フォームを表示するコンポーネント

import { useActionState } from "react";
import { InitAllowanceEntryState, type AllowanceEntryState } from "../libs/backendAPI";

type EntryProps = {
  formAction: (state: AllowanceEntryState, formData: FormData) => Promise<AllowanceEntryState>
}
export default function Entry({ formAction }: EntryProps) {
  const [state, action] = useActionState<AllowanceEntryState, FormData>(
    formAction, InitAllowanceEntryState);

  return (
    <form action={action} className="my-6 p-6 bg-white shadow-md rounded-lg">
      <h2 className="text-2xl font-bold mb-4">新しい項目を追加</h2>

      {state.errors.length > 0 &&
        <div className="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 " role="alert">
          <ul>
            {state.errors.map((error, index) => (
              <li key={index}> {error} </li>
            ))}
          </ul>
        </div>
      }

      <div className="space-y-4">
        <div>
          <label htmlFor="date" className="block text-sm font-medium text-gray-700">日付</label>
          <input type="date" defaultValue={state.date}
            name="date" id="date" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" />
        </div>
        <div>
          <label htmlFor="description" className="block text-sm font-medium text-gray-700">内容</label>
          <input type="text" defaultValue={state.description}
            name="description" id="description" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="例: おこづかい" />
        </div>
        <div>
          <label htmlFor="money_in" className="block text-sm font-medium text-gray-700">入金</label>
          <input type="text" defaultValue={state.money_in}
            name="money_in" id="money_in" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="0" />
        </div>
        <div>
          <label htmlFor="money_out" className="block text-sm font-medium text-gray-700">出金</label>
          <input type="text" defaultValue={state.money_out}
            name="money_out" id="money_out" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="0" />
        </div>
      </div>
      <div className="mt-6 text-right">
        <button type="submit" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
          追加
        </button>
      </div>
    </form>
  );
}
backendAPI.ts
// バックエンドのAPIとの通信を行うためのライブラリ

// バックエンドのサーバーのURL
const SERVER_URL = "http://localhost:8000/allowances";

// 小遣い帳の入力フォームの型
export type AllowanceEntryPost= {
  date: string;
  description: string;
  money_in: string;
  money_out: string;
};

// 小遣い帳の入力フォームの日本語名
const JpFeildNames = {
  "date": "日付",
  "description": "内容",
  "money_in": "入金",
  "money_out": "出金"
} as const;

type JpFeildKeys = keyof typeof JpFeildNames;

// 小遣い帳の入力フォームのState型
export type AllowanceEntryState = AllowanceEntryPost & {
  errors: string[];
};

// バックエンドから取得する小遣い帳のレコードの型
export type AllowanceRecord = {
  id: number;
  date: string;
  description: string;
  money_in: number | null;
  money_out: number | null;
  balance: number;
};

// バックエンドからのレスポンスのエラーの型
export type FastAPIValidationError = {
  loc: string[];
  msg: string;
  type: string;
};

// バックエンドからのレスポンスの型
export type FastAPIPostReturn = {
  detail: FastAPIValidationError[];
  message?: string;
}

// AllowanceEntryStateの初期状態
export const InitAllowanceEntryState: AllowanceEntryState = {
  date: "",
  description: "",
  money_in: "",
  money_out: "",
  errors: [],
};

// 小遣い帳のレコードを取得する関数
export const fetchAllowances = async (): Promise<AllowanceRecord[]> => {
  const response = await fetch(SERVER_URL);;
  return response.json();
}

// 小遣い帳のレコードを削除する関数
export const deleteAllowance = async (id: number): Promise<void> => {
  await fetch(`${SERVER_URL}/${id}`, { method: 'DELETE' });
}

// 小遣い帳のレコードを追加する関数
export const postAllowance = async (data: AllowanceEntryPost): Promise<FastAPIPostReturn> => {
  const response = await fetch(SERVER_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  const result = await response.json();
  return result;
}

// バックエンドからのエラー情報を文字列に変換する関数
export const makeErrorStrings = (errors: FastAPIValidationError[]): string[] => {
  return errors.map((e) =>
    e.loc.length < 2 ? e.msg : `${JpFeildNames[e.loc[1] as JpFeildKeys]}${e.msg}`
  );
}
useReload.ts
// useReloadは、コンポーネントの再レンダリングをトリガーするためのカスタムフックです。

import { useState } from "react";

export const useReload = (): [number, () => void] => {
  const [reload, setReload] = useState(1);
  const forceReload = () => setReload(prevState => prevState + 1);

  return [reload, forceReload];
}

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ