GETしただけなのにデータ変わってるんだけど?👀

GETしただけなのにデータ変わってるんだけど?👀

これ絶対あるじゃん??これ絶対あるじゃん??

  • GET /api/users/123を叩いたら、なぜかlast_accessed_atが更新されてた
  • 1件だけ見るはずの/api/orders/{id}、IDを渡さなくても動く。しかも全件返ってくる
  • 電話番号だけ変えたくてPUTしたら、名前もメールも消えた
  • PATCHPUTの使い分けが「なんとなく」で決まってる

あるよね? あるよね絶対??

これ全部、HTTPメソッドがウソついてるのが原因なんだよね。以降「ウソAPI」って呼ぶね。


てかシンプルに言うとねてかシンプルに言うとね

HTTPメソッドの「約束」を守ってないAPIってこと。

「それの何がまずいの?」ってなる気持ち、わかる!
GETは読むだけ。PUTは丸ごと入れ替え。PATCHは一部だけ直す。こんな感じで、HTTPのメソッドって1個ずつ「やっていいこと」が決まってるの。
その約束が壊れてると、返ってくるもの全部が信じられなくなるって話、ちゃんとするね。


ちょっと待って、先に全然関係ない話していい?🏠ちょっと待って、先に全然関係ない話していい?🏠

田中さんって受付係がいる市立図書館を想像してほしいんだけどさ。

この図書館、ルールが完全にイカれてたの。


ある日、鈴木さんって利用者が来て「この本、ちょっと中身を確認したいんですけど」って言ったの。田中さんがカウンターに本を持ってきた。ここまではいいじゃん。

でもそのとき、裏のパソコンが勝手に「貸出手続き」をやっちゃってたんだよね。

翌日、鈴木さんのスマホに通知が来た。

「延滞料100円が発生しています」

えっ、わたし見ただけなんだけど...

「田中さん、わたし借りてないんですけど」
「いえ、システム上は貸出になってますので...」

鈴木さん、めちゃくちゃ怒ってた。そりゃそうじゃん。


翌週。今度は館長が窓口に来て「鈴木さんの貸出状況を確認したい」って言ったんだよね。

田中さんが持ってきたのは——利用者全員の貸出リスト。500人分。

名前、住所、電話番号ぜんぶ載ってる。

いや、鈴木さんの分だけでよかったんだけど...

翌日、市役所から電話が来た。

「個人情報の取り扱いについて苦情が来てるんですが」

館長の顔が真っ青だった。


さらに翌週。鈴木さんが「引っ越したので住所だけ変えてほしい」って来たの。

田中さんが新しい住所を入力して、「更新完了しました!」って笑顔で返した。

翌日、鈴木さん宛の予約通知が届かない。会員情報を開いたら——住所以外が全部空っぽだったの💔

名前がない。電話番号もない。住所だけポツンと入ってるの。

住所を変えてって言っただけなのに、なんで他が全部消えてるの...

「田中さん、わたしの名前が消えてるんですけど」
「住所を登録いたしました!」

そうじゃなくて...

田中さん、翌月に辞めたんだって。マジで。

これが本番で起きてるやつが、ウソAPIってこと。


じゃあコードで見てみよっか👀じゃあコードで見てみよっか👀

さっきの図書館の話、コードに戻すとこうなるんだよね。田中さんの図書館 = このAPI。

from fastapi import FastAPI
from datetime import datetime

app = FastAPI()

# 「見せて」で貸出処理が走る = GETなのに書き込みが起きる
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
    user = db.find_user(user_id)
    user.last_accessed_at = datetime.now()
    db.save(user)  # GETなのにDBを書き換えてる
    return user

# 1人の情報を聞いたら全員分返ってくる = 詳細APIが一覧を兼ねてる
@app.get("/api/orders/{order_id}")
def get_order(order_id: int = None):
    if order_id is None:
        return db.find_all_orders()  # IDなしで全件返る
    return db.find_order(order_id)

# 住所だけ変えたら全部消える = PUTで部分更新をやってる
@app.put("/api/users/{user_id}")
def update_user(user_id: int, body: dict):
    db.replace(user_id, body)  # 送らなかったフィールドはnullになる
    return db.find_user(user_id)

これ、何が起きるかっていうとね。

  • GET /api/users/123を10回叩くとlast_accessed_atが10回変わる。しかもブラウザは「GETは読むだけでしょ」って思い込んでるから、古い結果を使い回すことがある。「見せて」のたびに裏でスタンプ押されてるのと同じ
  • GET /api/orders/にIDを渡し忘れたら、全顧客の注文履歴がダダ漏れになる。鈴木さんの分だけ聞いたのに500人分返ってきたやつ、あれ
  • PUT /api/users/123{"address": "新宿区..."}だけ送ったら、nameもemailもnullに吹き飛ぶ。住所だけ変えたかったのに全部消えたやつ

わたしも昔これやったから言えるんだけど、「GETだから読むだけでしょ」っていう当たり前が嘘になると、API叩くたびに「今回は何か変なこと起きてないよね?」って怖くなるんだよね。


やばいのまだあんだけど、バグに気づけない問題😭やばいのまだあんだけど、バグに気づけない問題😭

ウソAPIの怖いとこって「変なデータが返ってくる」だけじゃなくて、**「確認するたびにデータが変わるから、合ってるか間違ってるかわかんない」**のがほんとにやばい。

田中さんの図書館で「この本、今貸出中かどうか確認して」って言われたとする。

田中さんが確認のために本をカウンターに出した瞬間、貸出の手続きが始まっちゃうじゃん。

確認する行為自体がデータを変えちゃうの。 確認できないじゃん。

「鈴木さんの貸出状況だけ見たい」って聞いたのに全員分返ってくるから、鈴木さんの分が正しいかどうかも、他の499人のデータに埋もれてわかんない。

「住所だけ変わったか確認して」って言われても、確認のたびに名前とか電話番号が消えてるかもしれない。ほんとの情報がどれなのか、もう誰にもわかんないんだよね。


コードでも同じことが起きるんだよね。ちょっと見てみて?

GET /api/users/123のテストを書いたとする。テストを走らせるたびにlast_accessed_atが変わるから、走らせる順番が変わるだけで結果が変わる。

月曜に通ったテストが火曜に落ちる。原因? テストの走る順番が変わっただけ。

詳細APIのテストで「1件だけ返ること」を確認したいのに、IDの渡し方しだいで全件返ってくる。テスト用に作ったデータがぐちゃぐちゃになって、他のテストまで巻き添えで落ちる。

PUTのテストで「住所だけ変わること」を確認したいのに、送ってない情報が全部消える。テストが通ったように見えて、実は全然違うデータになってる。

ウソAPIは「バグがある」じゃなくて、**「何が正しいかわかんなくなる作り」**なんだよね。


分けてた会社の話もするね〜✨分けてた会社の話もするね〜✨

田中さんの図書館とは別に、最初からルールがちゃんと決まってた隣町の図書館の話もするね。

  • 「見せて」は見せるだけ。 貸出カードにスタンプ押すのは「借ります」って窓口で言ったときだけ
  • 「鈴木さんの貸出状況」は鈴木さんの分だけ返す。 「全員の一覧」は別の窓口で、権限がある職員だけが使える
  • 「住所だけ変えたい」は住所だけ変わる。 名前と電話番号はそのまま残る

何が起きなかった?

延滞料の苦情が来なかった。みんなの名前や住所がバレる事故も起きなかった。会員情報が消えてパニックにならなかった。受付の田中さんが辞めなかった。全員がいつも通り仕事して、いつも通り帰ったっていう。


コードに戻すとこうなるんだよね。隣町の図書館 = このAPI。

from fastapi import FastAPI

app = FastAPI()

# GETは読むだけ。何も変えない
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
    return db.find_user(user_id)  # 読んで返すだけ

# 一覧と詳細は別エンドポイント
@app.get("/api/orders")
def list_orders():
    return db.find_all_orders()  # 一覧用

@app.get("/api/orders/{order_id}")
def get_order(order_id: int):
    return db.find_order(order_id)  # 詳細用。IDは必須

# 一部だけ直すならPATCH
@app.patch("/api/users/{user_id}")
def update_user_partial(user_id: int, body: UserPartialUpdate):
    user = db.find_user(user_id)
    for key, value in body.dict(exclude_unset=True).items():
        setattr(user, key, value)  # 送ったフィールドだけ上書き
    db.save(user)
    return user

# 丸ごと置き換えるならPUT(全フィールド必須)
@app.put("/api/users/{user_id}")
def replace_user(user_id: int, body: UserFull):
    db.replace(user_id, body)  # 全フィールドを送る前提
    return db.find_user(user_id)

get_userは何も変えない。 100回叩いても同じものが返ってくる。ブラウザが結果を覚えておいても安全。

list_ordersget_orderは別のURL。 見ただけで「一覧か詳細か」がわかる。混ざらない。

update_user_partialは送ったフィールドだけ変える。 住所だけ送ったら住所だけ変わる。名前もメールもそのまま。

直すべき場所が1つだけ、返るものが予想通り——それだけで深夜のSlack通知が消えるんだよね。


ちゃんとした名前もあるから一応言うね📚ちゃんとした名前もあるから一応言うね📚

ここまで「ウソAPI」って呼んできたやつ、ちゃんとした名前がある。

REST(Representational State Transfer)。「HTTPの動きごとに『やっていいこと』を決めて、その通りにAPIを作ろうね」っていう約束ごとのこと。

Roy Fieldingって人が2000年の博士論文で言い出したやつ。この人、HTTP自体を作ったチームの1人でもあるんだよね。

この約束を守ったAPIを「RESTful」って呼ぶ。破ってるやつが、今日話した「ウソAPI」ね。

ざっくり言うとこう。

  • GET → 読む。何も変えない。何回やっても同じ結果
  • POST → 新しく作る
  • PUT → 丸ごと置き換える。何回やっても同じ結果
  • PATCH → 一部だけ直す
  • DELETE → 消す。何回やっても同じ結果

「何回やっても同じ結果」のことをべきとう(冪等)って呼ぶんだけど、図書館で言うと「本を見せて」を10回言っても本棚の中身は変わらないじゃん。それ。

難しそうに聞こえるけど、要は「このHTTPメソッド、約束通りに動いてる?」って問い続けるだけなんだよね。


これだけ覚えて帰って🥺これだけ覚えて帰って🥺

合言葉はこれ。umww(ウソメソッドわけわかんない)

API作るとき、レビューするとき、これだけ問いかけてみて。

「このURL、GETで10回叩いたら10回とも同じものが返ってくる?」

返ってこないなら、GETの中で何か変えてるっつーこと。図書館で「見せて」のたびにスタンプ押してるのと同じじゃん。

もう1個。

「このURLを見ただけで、1件返るか複数返るかわかる?」

/api/orders/123なら1件。/api/ordersなら複数。見てわかんないなら、URLの分け方がおかしいっつーこと。


まとめるとこういうことねまとめるとこういうことね

観点ウソAPIちゃんとしたAPI
GETデータを読む+書き込む読むだけ。何も変えない
1件だけ見るURLIDなしで全件返せる1件だけ。IDは必須
一覧と詳細同じURLで兼用別々のURLに分ける
PUT / PATCH区別なく使うPUTは丸ごと入れ替え、PATCHは一部だけ
テスト走らせる順番で結果が変わる何回叩いても結果が同じ

HTTPメソッドの約束を守る——それだけでAPI設計のバグは半分消えるっつーこと。


Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第14回


沼りたい人はこっちも読んで📖沼りたい人はこっちも読んで📖

書籍

  • Roy T. Fielding "Architectural Styles and the Design of Network-based Software Architectures" (2000) — RESTを最初に定義した博士論文。HTTPを作った本人が書いた
  • Leonard Richardson, Sam Ruby 著『RESTful Web Services』(2007, O'Reilly) — RESTful API設計の実践ガイド
  • Leonard Richardson, Mike Amundsen, Sam Ruby 著『RESTful Web APIs』(2013, O'Reilly) — 上記の発展版。ハイパーメディアの話も加えた

一次資料

無料で読める入門記事

  • MDN Web Docsの「HTTP リクエストメソッド」が日本語でも読める(上記URLから言語切り替え可能)
  • Zenn・Qiitaで「REST API設計 ベストプラクティス」で検索すると日本語の実装例が見つかる