ChatGPT先生と挑むFastAPI #4|CRUDとエラー処理

記事内に広告が含まれています。
タイトル

STEP 4では、これまで学んだGET・POSTに加えて、PUT・DELETEを使ったCRUD操作を学びます。

データの取得・登録・更新・削除というWeb APIの基本的な処理を一通り実装します。

また、HTTPExceptionを使ったエラー処理や、HTTPステータスコードの指定方法についても確認します。

最後にCRUD APIを自力で実装し、今回学んだ内容を理解できているか確認します。

ChatGPT先生
ChatGPT先生

それではSTEP4を始めましょう!

■■■スポンサーリンク■■■

今回学ぶこと

今回のステップで学ぶ内容を一覧で出してもらいました。

ChatGPT先生
ChatGPT先生

STEP4では以下の内容を学習します。

  1. CRUDとHTTPメソッドの対応を整理する
  2. CRUD確認用の簡単なデータを用意する
  3. GETでデータを取得する処理を作る
  4. PUTでデータを更新する
  5. DELETEでデータを削除する
  6. HTTPExceptionを使ってエラーを返す
  7. HTTPステータスコードを指定する
  8. CRUD全体を通して動作確認する
  9. 自力課題

学習開始

それでは学習をはじめます。

CRUDとHTTPメソッドの対応を整理する

ChatGPT先生
ChatGPT先生

CRUDは、データを扱うアプリケーションで基本となる4つの操作の頭文字です。

CRUD意味HTTPメソッド例
Create新規作成POST商品を登録する
Read取得GET商品を取得する
Update更新PUT商品情報を更新する
Delete削除DELETE商品を削除する

CRUD確認用の簡単なデータを用意する

ChatGPT先生
ChatGPT先生

今回は商品データを題材にします。

実際のWeb APIならデータはDBに保存しますが、今はCRUDとFastAPIの書き方に集中したいので、Pythonの辞書を簡易的なデータ保存先として使います。

まず、app/main.py に次のデータを追加してください。

追加するコード↓

# CRUDの動作確認用データ
products = {
    1: {
        "name": "ノートPC",
        "price": 120000,
        "description": "開発用PC",
    },
    2: {
        "name": "マウス",
        "price": 3000,
        "description": None,
    },
}
ChatGPT先生
ChatGPT先生

これから作るAPIでは、この辞書に対して次のような操作を行います。

GET     → productsから取得
POST    → productsへ追加
PUT     → productsを書き換え
DELETE  → productsから削除

GETでデータを取得する処理を作る

ChatGPT先生
ChatGPT先生

app/main.py に次のAPIを追加してください。

追加するコード↓

@app.get("/products/{product_id}")
def get_product(product_id: int):
    # 指定された商品IDのデータを取得する
    return products.get(product_id)
ChatGPT先生
ChatGPT先生

今回新しく注目したいのはproducts.get(product_id)です。

たとえば、GET /products/1とリクエストすると、products.get(1)が実行され、先ほど用意した次の商品が返ります。

{
  "name": "ノートPC",
  "price": 120000,
  "description": "開発用PC"
}
ChatGPT先生
ChatGPT先生

Swagger UIから、GET /products/1のリクエストを実行してください。上記の内容がレスポンスで返ってくるはずです。

実行結果↓

実行結果
ChatGPT先生
ChatGPT先生

続いて、GET /products/2も試してください。以下のレスポンスが返ってくればOKです。

{
  "name": "マウス",
  "price": 3000,
  "description": null
}

実行結果↓

実行結果

PUTでデータを更新する

ChatGPT先生
ChatGPT先生

まず、更新用のPydanticモデルを用意します。

STEP 3で作成した ProductCreate がすでにある場合は、それとは別に次を追加してください。

追加するコード↓

class ProductUpdate(BaseModel):
    name: str
    price: int = Field(gt=0)
    description: str | None = None
ChatGPT先生
ChatGPT先生

今回はPUTの基本を理解するため、name と price は必須にしています。
次に、更新APIを追加します。

追加するコード↓

@app.put("/products/{product_id}")
def update_product(product_id: int, product: ProductUpdate):
    # 指定された商品IDのデータを更新する
    products[product_id] = product.model_dump()

    return products[product_id]
ChatGPT先生
ChatGPT先生

ここで重要なのは、引数の役割です。

product_id: intはURLの/products/{product_id}に対応するため、パスパラメータです。一方、product: ProductUpdateはPydanticモデルなので、FastAPIはこれをリクエストボディとして扱います。

つまり、PUT /products/1に対して以下のデータを送ると、products[1]の中身がこのデータに置き換わります。

{
  "name": "ゲーミングノートPC",
  "price": 150000,
  "description": "開発・ゲーム用PC"
}
ChatGPT先生
ChatGPT先生

ここもFastAPIというよりPydantic側のポイントです。

product.model_dump()

product は ProductUpdate オブジェクトなので、そのままでは今回の products に入っている普通の辞書とは形式が異なります。

model_dump() を使うことで、

ProductUpdate(
    name="ゲーミングノートPC",
    price=150000,
    description="開発・ゲーム用PC",
)

を、

{
    "name": "ゲーミングノートPC",
    "price": 150000,
    "description": "開発・ゲーム用PC",
}

というPythonの辞書に変換できます。

ChatGPT先生
ChatGPT先生

Swagger UIで、PUT /products/1を選び、リクエストボディを例えば次のようにしてください。

{
  "name": "ゲーミングノートPC",
  "price": 150000,
  "description": "開発・ゲーム用PC"
}

実行して、同じ内容でレスポンスが返ってくればOKです。

実行結果↓

実行結果
ChatGPT先生
ChatGPT先生

さらに先ほど作った、GET /products/1をもう一度実行してください。

PUTで更新した内容がGETでも返ってくれば、メモリ上の products が実際に更新されたことまで確認できます。

実行結果↓

実行結果
ChatGPT先生
ChatGPT先生

なお、このコードにはまだ1つ問題があります。

PUT /products/999のような存在しないIDを指定すると、新しい商品として登録されてしまいます。

これは本来の今回のAPIでは望ましくないので、後ほど HTTPException を学ぶ項目で修正します。

DELETEでデータを削除する

ChatGPT先生
ChatGPT先生

app/main.py に、次のAPIを追加してください。

追加するコード↓

@app.delete("/products/{product_id}")
def delete_product(product_id: int):
    # 指定された商品IDのデータを削除する
    deleted_product = products.pop(product_id)

    return deleted_product
ChatGPT先生
ChatGPT先生

今回のポイントはproducts.pop(product_id)です。

Pythonの辞書で pop() を使うと、

  • 指定したキーのデータを辞書から削除する
  • 削除した値を戻り値として返す

という2つのことを同時に行えます。

たとえば、products.pop(2)を実行すると、商品ID2のデータがproductsから削除され、その削除された商品データがdeleted_productに入ります。

ChatGPT先生
ChatGPT先生

Swagger UIから、まずDELETE /products/2を実行してください。

次のレスポンスが返れば削除処理自体は成功しています。

{
  "name": "マウス",
  "price": 3000,
  "description": null
}

実行結果↓

実行結果
ChatGPT先生
ChatGPT先生

もう一度、GET /products/2を実行してみてください。

商品2が削除されていれば null が返るはずです。

実行結果↓

実行結果
ChatGPT先生
ChatGPT先生

ここでも、あえてまだ問題を残しています。

DELETE /products/999のように存在しないIDを指定すると、products.pop(product_id) で KeyError が発生します。

これも、後ほど HTTPException を学ぶ項目で修正します。

HTTPExceptionを使ってエラーを返す

ChatGPT先生
ChatGPT先生

FastAPIでは、HTTPエラーを返したいときに HTTPException を使います。

まず、app/main.py のimportを修正してください。

修正コード↓

from fastapi import FastAPI, HTTPException
ChatGPT先生
ChatGPT先生

HTTPException を追加したら、まずGETを次のように修正します。

修正コード↓

@app.get("/products/{product_id}")
def get_product(product_id: int):
    # 指定された商品IDの商品を取得する
    product = products.get(product_id)

    # 商品が存在しない場合は404エラーを返す
    if product is None:
        raise HTTPException(
            status_code=404,
            detail="Product not found",
        )

    return product

ここで重要なのが以下です。

raise HTTPException(
    status_code=404,
    detail="Product not found",
)

普通のPythonでは、例外は「予期しない問題が起きた」ときに使うイメージが強いですが、FastAPIでは HTTPException を使って、このリクエストに対して、このHTTPエラーを返すという処理を明示できます。

ChatGPT先生
ChatGPT先生

現在のPUTは、存在しないIDでも新しくデータを作れてしまいました。

それを防ぐため、更新前に存在チェックを追加します。

修正コード↓

@app.put("/products/{product_id}")
def update_product(product_id: int, product: ProductUpdate):
    # 商品が存在しない場合は404エラーを返す
    if product_id not in products:
        raise HTTPException(
            status_code=404,
            detail="Product not found",
        )

    # 指定された商品IDのデータを更新する
    products[product_id] = product.model_dump()

    return products[product_id]

これで、PUT /products/999を実行しても、新しい商品は作られません。

ChatGPT先生
ChatGPT先生

DELETEも同じ考え方です。

修正コード↓

@app.delete("/products/{product_id}")
def delete_product(product_id: int):
    # 商品が存在しない場合は404エラーを返す
    if product_id not in products:
        raise HTTPException(
            status_code=404,
            detail="Product not found",
        )

    # 指定された商品IDのデータを削除する
    deleted_product = products.pop(product_id)

    return deleted_product

これで、存在しないIDを指定しても KeyError ではなく、APIとして意味のある404エラーになります。

ChatGPT先生
ChatGPT先生

Swagger UIで、次の3つを試してください。

GET /products/999
PUT /products/999
DELETE /products/999

いずれもレスポンスのHTTPステータスが、404となり、以下のレスポンスが返ってくればOKです。

{
  "detail": "Product not found"
}

GET /products/999の実行結果↓

GETリクエストの実行結果

PUT /products/999の実行結果↓

PUTリクエストの実行結果

DELETE /products/999の実行結果↓

DELETEPUTリクエストの実行結果
ChatGPT先生
ChatGPT先生

ここでの重要なポイントは、次のような考え方です。

存在しないデータ
↓
Python内部のエラーとして落とすのではなく
↓
API利用者に意味のあるHTTPエラーとして返す

DRFでいう NotFound や、get_object_or_404() などに近い役割ですね。

404をAPIドキュメントに反映させるには?

該当データが存在しない場合に404を返すように修正しましたが、Swagger UIのAPIドキュメントには404のレスポンスが反映されていませんでした。

そこでChatGPT先生にAPIドキュメントに404を反映させる方法を聞きました。

ChatGPT先生
ChatGPT先生

HTTPException で実際に 404 を返す処理を書いても、Swagger UI(OpenAPIドキュメント)にはその404レスポンスは自動では追加されません。

Swagger UIに「このAPIは404を返す可能性があります」と記載したい場合は、デコレーターの responses に明示します。

@app.get(
    "/products/{product_id}",
    responses={
        404: {
            "detail": "Product not found",
        },
    },
)

上記のように、デコレータに404を追記したらSwagger UIのAPIドキュメントにも404が表示されました。

APIドキュメントに追加された404

だた、これだとデコレータ部分のコード量がかなり多くなりそうだと感じたので、実際どうなのか聞いてみました。

ChatGPT先生
ChatGPT先生

はい、その感覚はかなり合っています。FastAPIでAPIドキュメントを丁寧に書こうとすると、デコレータ側の情報量は増えやすいです。

ただし、毎回すべてをベタ書きするわけではありません。実務ではいくつか整理する方法があります。これはちょうど次の STEP 5「Router・Dependsと構成整理」 で触れる部分です。

とのことでした。実際にはデコレータを整理する方法の例をいくつか出してもらったのですが、次のSTEPで触れるとのことなので、ここでは省略します。

HTTPステータスコードを指定する

ChatGPT先生
ChatGPT先生

これまで作ったAPIは、特に指定しなければ正常終了時に基本的に200 OKを返します。

ただし、処理の内容によって適切なステータスコードは異なります。代表的なものは次のとおりです。

ステータス意味今回の例
200 OK正常終了GET、PUT
201 Created新しいデータを作成したPOST
204 No Content正常終了したがレスポンスボディなしDELETE
404 Not Found対象が存在しない前の項目で実装済み
422 Unprocessable ContentバリデーションエラーPydanticで経験済み
ChatGPT先生
ChatGPT先生

FastAPIでは数値を直接書くこともできますが、今回は意味が分かりやすいように status を使います。

まずimportを変更します。

修正コード↓

from fastapi import FastAPI, HTTPException, status
ChatGPT先生
ChatGPT先生

POSTを 201 Created にします。

STEP 3で作成したPOST APIのデコレーターを次のように修正します。

修正コード↓

@app.post(
    "/products",
    status_code=status.HTTP_201_CREATED,
)
def create_product(product: ProductCreate):
    # 商品登録処理
    ...
ChatGPT先生
ChatGPT先生

status_code=status.HTTP_201_CREATEDとすることで処理が正常に完了すると、201 Createdが返るようになります。

DELETEは今回は200のままにします。DELETEでは 204 No Content が使われることも多いのですが、現在のAPIは、return deleted_productとして削除した商品をレスポンスとして返しています。

204 No Content は名前どおり、レスポンスボディを返さない場合に使うステータスです。そのため、今回はあえてそのままにしておきます。

DELETEだから必ず204というわけではなく、APIのレスポンス設計に合わせてステータスコードを選ぶのがポイントです。

それでは、Swagger UIから既存のPOST APIを実行してください。ステータスが201になればOKです。

実行結果↓

実行結果
ChatGPT先生
ChatGPT先生

今回のポイントは、次のように整理すると分かりやすいです。

HTTPException:異常系のステータスコードを指定
status_code:正常系のステータスコードを指定

CRUD全体を通して動作確認する

ChatGPT先生
ChatGPT先生

ここでは新しい実装は増やさず、これまで作ったAPIを Create → Read → Update → Delete の順番で実際に操作して、CRUDとして一連の流れが成立しているか確認します。

今回は、1つの商品を登録してから削除するところまで順番に試してみましょう。
一連の流れとしては、次のようになります。

POST
商品を作成
↓
GET
作成した商品を取得
↓
PUT
商品を更新
↓
GET
更新されたことを確認
↓
DELETE
商品を削除
↓
GET
404になることを確認

Create:商品を登録する

ChatGPT先生
ChatGPT先生

Swagger UIから、POST /productsを実行します。

例えば次の商品を登録してください。

{
  "name": "キーボード",
  "price": 8000,
  "description": "開発用キーボード"
}

Status Code: 201になっていることを確認してください。POST実行後に、その商品のIDを使って、GET /products/{product_id}を実行してみてください。

ここでPOSTした商品をGETできれば、Create → Read がつながっています。

ChatGPT先生は「POSTで登録した商品をGETリクエストで取得しろ」と言っていますが、現状のPOSTの処理はリクエストボディをそのまま返すだけの処理になっているため、追加も何もしていません。

# 現在のPOSTの処理
@app.post(
        "/products",
        status_code=status.HTTP_201_CREATED,
)
def create_product(product: ProductCreate):
    # リクエストボディから受け取った商品データをそのまま返す
    return product

その旨を伝えた所、以下のようにPOSTを修正しろと指示がありました。

@app.post(
    "/products",
    status_code=status.HTTP_201_CREATED,
)
def create_product(product: ProductCreate):
    # 現在の商品IDの最大値から、新しい商品IDを作成する
    new_product_id = max(products.keys(), default=0) + 1

    # Pydanticモデルを辞書に変換する
    product_data = product.model_dump()

    # 新しい商品を保存する
    products[new_product_id] = product_data

    # 作成された商品IDと商品データを返す
    return {
        "id": new_product_id,
        **product_data,
    }
ChatGPT先生
ChatGPT先生
new_product_id = max(products.keys(), default=0) + 1

この処理は今回のメモリ上の簡易データ用です。実際にDBを使うSTEP 6では、IDの採番は通常DB側に任せるので、この処理を自分で書く必要はなくなります。

今回はこちらの進め方に抜けがありました。STEP 3のPOSTをそのままCRUDのCreateとして扱ってしまっていました。

まずこの修正を入れて、POSTした商品のレスポンスに id が含まれ、そのIDを使ったGETで同じ商品を取得できるか確認してください。

言われた通り処理を修正してPOST /productsを実行。
実行結果↓

POSTの実行結果

Status Code:201で修正後コードのレスポンス形式で返ってきていることが確認できました。

GET /products/3でPOSTとしたデータを取得した結果↓

POSTしたデータをGETした結果

問題なく取得できました。

Update:登録した商品を更新する

ChatGPT先生
ChatGPT先生

同じIDに対して、PUT /products/{product_id}を実行し、次のように変更します。

{
  "name": "メカニカルキーボード",
  "price": 12000,
  "description": "開発用メカニカルキーボード"
}

その後、GET /products/{product_id}を実行し、更新後の内容が返ってくることを確認します。

先ほど登録したデータのproduct_idは「3」なので、{product_id}を3に置き換えて実行していきます。

PUT /products/3の実行結果↓

PUTの実行結果

GET /products/3の実行結果↓

PUTしたデータをGETした結果

PUTでデータが更新され、GETで更新されたデータを取得することができました。

Delete:商品を削除する

ChatGPT先生
ChatGPT先生

最後に、DELETE /products/{product_id}で同じ商品を削除します。

その後もう一度、GET /products/{product_id}を実行します。

今度は、Status Code: 404で、以下のようになればOKです。

{
  "detail": "Product not found"
}

DELETE /products/3の実行結果↓

DELETEの実行結果

GET /products/3の実行結果↓

DELETEしたデータをGETした結果

想定通りの結果になりました。

自力課題

ChatGPT先生
ChatGPT先生

今回は、ここまでの商品APIを参考にしながら、別のリソースのCRUD APIを自力で作ってみましょう。

課題内容

ChatGPT先生
ChatGPT先生

題材は 書籍(Book)API にします。

メモリ上に書籍を保存する books 辞書を用意し、次のAPIを実装してください。

HTTPメソッドパス処理
POST/books書籍を登録する
GET/books/{book_id}書籍を1件取得する
PUT/books/{book_id}書籍を更新する
DELETE/books/{book_id}書籍を削除する
ChatGPT先生
ChatGPT先生

書籍には、次の項目を持たせてください。

title        書籍名
price        価格
description  説明(任意)

条件は次のとおりです。

  • title は文字列・必須
  • price は整数・必須・0 より大きい値
  • description は文字列または None・省略可能
  • POSTでは新しいIDを採番して books に保存する
  • POST成功時は 201 Created
  • GET / PUT / DELETEで存在しないIDを指定した場合は 404 Not Found
  • エラー時の detail は "Book not found" とする
  • PUTでは title・price・description をまとめて更新する
  • DELETE成功時は、削除した書籍データを返す

完了条件

ChatGPT先生
ChatGPT先生

Swagger UIを使って、次の流れがすべて成功すれば課題クリアです。

POST
↓
作成したIDでGET
↓
PUTで内容を変更
↓
GETで変更を確認
↓
DELETE
↓
GETして404を確認

実装結果

実装したコード(実装箇所のみ抜粋)↓

class BookCreate(BaseModel):
    title: str
    price: int = Field(gt=0)
    description: str | None = None


class BookUpdate(BaseModel):
    title: str
    price: int = Field(gt=0)
    description: str | None = None


books = {}


@app.post(
    "/books",
    status_code=status.HTTP_201_CREATED,
)
def create_book(book: BookCreate):
    # 現在の書籍IDの最大値から、新しい書籍IDを作成する
    new_book_id = max(books.keys(), default=0) + 1

    # Pydanticモデルを辞書に変換する
    book_data = book.model_dump()

    # 新しい書籍を保存する
    books[new_book_id] = book_data

    # 作成された書籍IDと書籍データを返す
    return {
        "id": new_book_id,
        **book_data,
    }


@app.get("/books/{book_id}")
def get_book(book_id: int):
    book = books.get(book_id)

    # 書籍が存在しない場合は404エラーを返す
    if book is None:
        raise HTTPException(
            status_code=404,
            detail="Book not found",
        )

    return book


@app.put("/books/{book_id}")
def update_book(book_id: int, book: BookUpdate):
    # 書籍が存在しない場合は404エラーを返す
    if book_id not in books:
        raise HTTPException(
            status_code=404,
            detail="Book not found",
        )

    # 指定された書籍IDのデータを更新する
    books[book_id] = book.model_dump()

    return books[book_id]


@app.delete("/books/{book_id}")
def delete_book(book_id: int):
    # 書籍が存在しない場合は404エラーを返す
    if book_id not in books:
        raise HTTPException(
            status_code=404,
            detail="Book not found",
        )

    # 指定された書籍IDのデータを削除する
    deleted_book = books.pop(book_id)

    return deleted_book

POSTの実行結果↓

POSTの実行結果

作成したIDでGETした結果↓

作成したIDでGETした結果

ちなみに、存在しないIDをGETしたときは404が返るようにしています。

存在しないIDをGETした結果

PUTで内容を変更した結果↓

PUTの実行結果

PUTしたデータをGETした結果↓

PUTしたデータをGETした結果

ちなみに、存在しないIDをPUTしたときは404が返るようにしています。

存在しないIDをPUTした結果

DELETEした結果↓

DELETEの実行結果

DELETEしたIDをGETした結果↓

DELETEしたIDをGETした結果

ちなみに、存在しないIDをDELETEしたときは404が返るようにしています。

ChatGPT先生のレビュー結果

ChatGPT先生
ChatGPT先生

かなりきれいに書けています。要件も満たせています。
コード上で気になる点を挙げるなら1つだけです。

books = {}

この書き方でも動作上は問題ありませんが、型を少し明確にするなら例えば、以下のようにも書けます。

books: dict[int, dict] = {}

ただし、今回の自力課題では必須ではありません。今のままで十分です。

実行結果もすべて問題ありません。自力課題はクリアです。

まとめ

今回はCRUDとエラー処理について学びました。

CRUDとHTTPメソッドについては、既知の内容だったため、いい復習になりました。

FastAPIでは以下のように使い分けることを学びました。

@app.post(...)  # 登録
@app.get(...)  # 取得
@app.put(...)  # 更新
@app.delete(...)  # 削除

また、APIの処理中に任意のエラーを返したい場合は、HTTPException を使用するということも学びました。

今回は該当データがない場合にHTTPException で404を返すようにしましたが、他にも相関チェックやAPIの処理中にエラーと判断した場合など、処理に応じたHTTPステータスを返す際にHTTPExceptionを使うのだと理解しました。

今回までの学習で一通りのCRUD操作を実装して、基本的な実装については理解できたかなと感じています。

コメント

タイトルとURLをコピーしました