一覧WB-14-2 · Web APIとアプリ構成

APIのデータ取得と変更通知

商品名と価格をGraphQLで取得し、項目の追加、未定義項目、部分的な失敗を比較します。次に商品・在庫・配送情報をブラウザから個別取得する場合とBFFで集約する場合を確認します。最後に、同じWebhookの再送と、通知後の最新状態の取得を試します。

説明

画面に必要なデータを得る方法には、複数APIを呼ぶ、GraphQLで返すフィールドを指定する、画面用のAPIで複数サービスの結果をまとめる、といった方法があります。Webhookは取得への応答ではなく、変更が起きたときに送られる通知です。

商品名と価格をGraphQLで取得し、項目の追加、未定義項目、部分的な失敗を比較します。次に商品・在庫・配送情報をブラウザから個別取得する場合とBFFで集約する場合を確認します。最後に、同じWebhookの再送と、通知後の最新状態の取得を試します。

1必要な項目を選ぶ

商品1のidとnameだけを指定します。

await(await fetch("/graphql",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:GraphQL問い合わせ'{ product(id: "1")商品を引数で指定 { id name }返すフィールドを選択 }'})})).json()

data.productには指定したidとnameが返ります。GraphQLはスキーマで定義されたフィールドを選択する問い合わせ言語です。任意のデータベース列へ自由にアクセスできるという意味ではありません。

2価格を選択へ加える

同じ商品の選択にpriceを追加します。

await(await fetch("/graphql",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:'{ product(id: "1") { id name price }価格を追加して選択 }'})})).json()

価格120も返ります。クライアントは必要なフィールドを選べますが、裏側でどのサービスやデータベースを呼ぶかはサーバーのresolver実装によります。返す項目が少ないことだけで処理負荷の小ささは保証されません。

3未定義のフィールド

priceを誤ってcostという名前で指定します。

await(await fetch("/graphql",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:'{ product(id: "1") { id name cost }スキーマにない項目を指定 }'})})).json()

Productにcostは定義されていないためerrorsが返ります。問い合わせの検証エラーでは、実行結果のdataを返さない形になります。HTTP状態だけでなくGraphQL応答内のerrorsも確認します。

4一部だけが失敗する応答

在庫の取得先が失敗する条件で、nameとstockを要求します。

await(await fetch("/graphql?stock=fail在庫取得先を失敗させる条件",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:'{ product(id: "1") { name stock }成功項目と失敗項目を要求 }'})})).json()

nameは返りますが、nullableなstockはnullで、errorsのpathがproduct→stockを示します。GraphQLではdataとerrorsが同時に返る場合があります。必須のNon-Nullフィールドが失敗した場合には、nullが親へ伝わる範囲も変わります。

5三サービスを個別に取得する

商品・在庫・配送を三つの要求で取得します。

({product:await(await fetch("/service/product")商品情報).json(),stock:await(await fetch("/service/stock")在庫情報).json(),shipping:await(await fetch("/service/shipping")配送情報).json()})

ブラウザが三つの応答を組み合わせる材料を受け取ります。この例では順に待っていますが、独立した取得は並行にすることもできます。どの方法でも、各サービスの失敗やデータの時点差をどう扱うかが必要です。

6画面用APIで集約する

BFFの応答を一回取得します。

await(await fetch("/bff")画面用の集約API).json()

画面で使うtitle・price・available・deliveryDaysにまとまっています。BFFは特定のフロントエンド向けに取得や変換をまとめる層です。ブラウザの要求は一回になっても、裏側のサービス呼出しが消えるわけではありません。

7裏側の呼出しも確認する

サービスの呼出し元を含む記録を表示します。

await(await fetch("/trace")外側と内側の呼出し記録).json()

最初の三件はbrowserから、BFFの後の三件はbffから呼ばれています。通信の段数と責任の置き場所が変わったことが分かります。集約側ではタイムアウトや部分失敗、認可の扱いも必要になります。

8Webhookを受信する

在庫が7になったという通知を送信元から送らせます。

await(await fetch("/emit"送信元から通知を送る,{method:"POST"通知発行の操作})).json()

受信側は本文のHMAC-SHA-256署名を検査し、イベントID=evt-1を初めて処理します。署名対象は受け取った本文バイト列です。JSONを解析して再出力した文字列では、空白や順序が変わって署名が一致しない場合があります。

9同じ通知を再送する

同じイベントIDと本文をもう一度送らせます。

(await fetch("/emit"同じイベントを再送,{method:"POST"}),await(await fetch("/webhook-records")受信回数と処理回数).json())

二回受信していますが、processedは1です。配信側が応答を受け取れず再送する場合などに備え、イベントIDで重複処理を防ぎます。受信記録と業務処理回数は別に数えます。

10通知の値と最新値が異なる場合

通知後に在庫が6になった状態を取得します。

(await fetch("/stock",{method:"POST",body:'{"stock":6}'通知後の状態を更新}),{notification:通知に含まれる過去値(await(await fetch("/webhook-records")).json()).received[0].stock,current:APIから取得する現在値await(await fetch("/service/stock")).json()})

通知時点は7、現在値は6です。通知の到着順や遅延を考慮し、通知を変更のきっかけとして最新状態を再取得する設計もあります。すべての通知の値をそのまま現在値へ上書きするとは限りません。

11署名が一致しない通知

署名を誤った値にした本文を受信先へ送ります。

(globalThis.bad=await fetch("/receiver",{method:"POST",headers:{"Content-Type":"application/json","X-Signature":"00"不一致の署名},body:'{"id":"evt-2","stock":0}'}),{status:bad.status受信先の拒否,body:await bad.json(),processed:処理件数が増えていないか(await(await fetch("/webhook-records")).json()).processed})

401で拒否され、processedは1のままです。送信元の検証と重複の検査は別の目的を持ちます。正しい署名の再送もあり得るため、署名だけで一回性は保証されません。

まとめ

GraphQLはスキーマ内の返却項目を選び、部分成功ではdataとerrorsを併せて扱います。

BFFは画面側の取得をまとめますが、裏側の呼出しと失敗への対応は残ります。

Webhookは署名検証・重複防止・通知時点と最新状態を分けて扱います。

操作履歴と解説を振り返れます。

ブラウザ / Web API

1
await(await fetch("/graphql",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:'{ product(id: "1") { id name } }'})})).json()
{
  "data": {
    "product": {
      "id": "1",
      "name": "ノート"
    }
  }
}
2
await(await fetch("/graphql",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:'{ product(id: "1") { id name price } }'})})).json()
{
  "data": {
    "product": {
      "id": "1",
      "name": "ノート",
      "price": 120
    }
  }
}
3
await(await fetch("/graphql",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:'{ product(id: "1") { id name cost } }'})})).json()
{
  "errors": [
    {
      "message": "Cannot query field \"cost\" on type \"Product\"."
    }
  ]
}
4
await(await fetch("/graphql?stock=fail",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:'{ product(id: "1") { name stock } }'})})).json()
{
  "data": {
    "product": {
      "name": "ノート",
      "stock": null
    }
  },
  "errors": [
    {
      "message": "Stock service unavailable",
      "path": [
        "product",
        "stock"
      ]
    }
  ]
}
5
({product:await(await fetch("/service/product")).json(),stock:await(await fetch("/service/stock")).json(),shipping:await(await fetch("/service/shipping")).json()})
{
  "product": {
    "id": 1,
    "name": "ノート",
    "price": 120
  },
  "stock": {
    "stock": 10
  },
  "shipping": {
    "days": 2
  }
}
6
await(await fetch("/bff")).json()
{
  "title": "ノート",
  "price": 120,
  "available": true,
  "deliveryDays": 2
}
7
await(await fetch("/trace")).json()
[
  {
    "path": "/service/product",
    "caller": "browser"
  },
  {
    "path": "/service/stock",
    "caller": "browser"
  },
  {
    "path": "/service/shipping",
    "caller": "browser"
  },
  {
    "path": "/bff",
    "caller": "browser"
  },
  {
    "path": "/service/product",
    "caller": "bff"
  },
  {
    "path": "/service/stock",
    "caller": "bff"
  },
  {
    "path": "/service/shipping",
    "caller": "bff"
  }
]
8
await(await fetch("/emit",{method:"POST"})).json()
{
  "accepted": true,
  "duplicate": false
}
9
(await fetch("/emit",{method:"POST"}),await(await fetch("/webhook-records")).json())
{
  "received": [
    {
      "id": "evt-1",
      "stock": 7,
      "duplicate": false
    },
    {
      "id": "evt-1",
      "stock": 7,
      "duplicate": true
    }
  ],
  "processed": 1
}
10
(await fetch("/stock",{method:"POST",body:'{"stock":6}'}),{notification:(await(await fetch("/webhook-records")).json()).received[0].stock,current:await(await fetch("/service/stock")).json()})
{
  "notification": 7,
  "current": {
    "stock": 6
  }
}
11
(globalThis.bad=await fetch("/receiver",{method:"POST",headers:{"Content-Type":"application/json","X-Signature":"00"},body:'{"id":"evt-2","stock":0}'}),{status:bad.status,body:await bad.json(),processed:(await(await fetch("/webhook-records")).json()).processed})
{
  "status": 401,
  "body": {
    "error": "invalid_signature"
  },
  "processed": 1
}

演習完了