5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

API統合の信頼境界:OAuth・外部data・本文deadlineと上限付き読取test

5
Last updated at Posted at 2025-03-25

2026-10-12 訂正:旧OAuth例のstate保存漏れ・Math.random・browserのclient_secret/refresh token、GitHub説明の無加工HTML挿入、無限retryと構文エラー、本文を覆わないtimeoutを修正しました。完成済みのAPI統合として扱わず、危険な例を取り下げ、検証範囲を限定した読取例へ整理しています。

通信方式と認可を混同しない

REST・SOAP・GraphQL・WebSocket・gRPCは同じ選定軸だけで比較できません。operation、schema、transport、stream、互換性・運用の要件から候補を比べます。HTTPのPOSTが常に作成というわけでもなく、個別API仕様を確認します。

GraphQLの例としてGitHub GraphQL APIは挙げられますが、名前の似たFacebook Graph APIをそのままGraphQLの例にした旧説明を取り下げます。この訂正ではMetaの個別仕様を一次確認できていません。GraphQLも必要fieldを選べるだけで、query cost・N+1・権限・paginationが自動解決するわけではありません。

OAuthは主に認可のframeworkです。login/本人認証を行う場合はOIDC等の仕様とID token検証を別に確認します。OAuthの現行security指針RFC 9700を参照し、authorization codeとPKCE、transactionに結び付いたCSRF対策、redirect URIの一致を設計します。旧例のようにMath.randomでstateを作り、保存せず比較する実装を使いません。

browserのpublic clientにclient_secretを置いて秘密を保てるとは扱いません。BFF等を選ぶならtokenをserver側に保持し、session cookie・CSRF・期限と失効も設計します。refresh tokenをlocalStorageへ常設する例は取り下げました。OAuth/API keyの新規設定・認可操作・外部接続は今回実施していません。

読取の試行回数・本文・中断を一つの期限で扱う

旧retry例では5xx時に例外を出さず回数上限を確認できず、4xxもcatchでretryしていました。以下はNode.js 22以降のGET専用の小例です。設定済みoriginだけを許可し、502/503/504だけ最大三回、redirectを拒否、本文64KiBと全体deadlineを設けます。example.comは架空の接続先で、実providerには接続していません。

import {setTimeout as wait} from 'node:timers/promises';
// Node.js 22+; the example origin must be replaced by approved server configuration.
export async function readJson(url, {signal, timeoutMs=5000, maxAttempts=3, fetchImpl=fetch}={}) {
  const target=new URL(url);
  if(target.origin!=='https://api.example.com'||target.username||target.password)throw new TypeError('origin not allowed');
  if(!Number.isInteger(timeoutMs)||timeoutMs<1||timeoutMs>30000||!Number.isInteger(maxAttempts)||maxAttempts<1||maxAttempts>3)throw new TypeError('invalid limits');
  const deadline=new AbortController();
  const combined=signal?AbortSignal.any([signal,deadline.signal]):deadline.signal;
  const timer=setTimeout(()=>deadline.abort(new DOMException('Deadline exceeded','TimeoutError')),timeoutMs);
  try {
    for(let attempt=1;attempt<=maxAttempts;attempt++){
      combined.throwIfAborted();
      const response=await fetchImpl(target.href,{method:'GET',redirect:'error',signal:combined});
      combined.throwIfAborted();
      if([502,503,504].includes(response.status)&&attempt<maxAttempts){
        await response.body?.cancel();
        await wait(100*attempt,undefined,{signal:combined});
        continue;
      }
      if(!response.ok){await response.body?.cancel();throw new Error('HTTP '+response.status);}
      if(!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type')??'')){await response.body?.cancel();throw new TypeError('JSON expected');}
      const reader=response.body?.getReader();
      if(!reader)throw new TypeError('body missing');
      const chunks=[];let bytes=0;
      try {
        while(true){
          combined.throwIfAborted();
          const {done,value}=await reader.read();
          combined.throwIfAborted();
          if(done)break;
          bytes+=value.byteLength;
          if(bytes>65536)throw new RangeError('body too large');
          chunks.push(value);
        }
      }catch(error){await reader.cancel().catch(()=>{});throw error;}
      finally{reader.releaseLock();}
      const data=JSON.parse(Buffer.concat(chunks).toString('utf8'));
      combined.throwIfAborted();
      return data;
    }
  }finally{clearTimeout(timer);}
}
export function validateUser(data){
  if(!data||typeof data!=='object'||Array.isArray(data)||!Number.isSafeInteger(data.id)||data.id<=0||typeof data.name!=='string'||!data.name.trim()||data.name.length>100||typeof data.email!=='string'||data.email.length>254||! /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email))throw new TypeError('invalid user');
  return {id:data.id,name:data.name.trim(),email:data.email};
}

外部signalを置き換えず、deadlineと合わせます。header取得後も本文を読む間まで期限を残し、成功・失敗ともtimerを解放します。404・429・JSON不正・Content-Type不一致・network errorはこの例では自動retryしません。429では実providerのRetry-After、全体budget、quotaを確認して別に再開判断します。中断前、await後、返却前の確認は、それぞれ次の副作用や結果採用を防ぐためです。

この例の限界は、固定originからの読取専用で、認証・秘密・pagination・cache・schema全般を備えたclientではないことです。実装されたfetchがsignalを守ることを前提にしています。DNS/egress制御・全endpointの許可・concurrency・監視等は環境側で確認する必要があります。POSTの決済・送信には転用しません。timeoutでも既に行われた外部操作を取消したことにはならず、結果照合と二重実行防止が別に必要です。

Nodeで20項目を、Response/ReadableStreamのローカルmockだけで確認しました。成功、5xx三回停止、404一回、JSON/Content-Type/サイズ不正、origin/上限不正、開始前とheader後の中断、本文待ち中のdeadline、user dataの型・ID・name・emailを含みます。実APIの障害復旧・課金・成果を確認したtestではありません。

外部dataを表示する境界

旧GitHub例はname・description等をtemplate文字列でinnerHTMLへ渡していました。この表示codeを取り下げます。外部文字列はtextContent等で文字として挿入し、linkはHTTPSと許可hostnameを検証してからDOMへ設定します。HTMLを許す用途なら専用のsanitizationとpolicyを別に設計します。

GitHubのwatchers_countをwatch通知購読者数として表示しません。このfieldはstar数との互換fieldです。starはstargazers_count、購読者なら対象APIのsubscribers_count等の定義・取得方法を照合します。公式repository APIを参照してください。十件取得を全repositoryと扱わず、pagination・rate limit・0件・権限と404を区別します。

旧email regex末尾のescaped dollarはリテラル$に一致していました。上の末尾anchorへ直しました。ただし、この簡易regexは全email仕様の検証・本人確認ではありません。local partを勝手に小文字化しません。数値IDもparseIntで途中まで読んで受理せず、正のsafe integerというこの例の契約で検証します。

testの版と、実clientの存在を確認する

旧ApiClientにはgetUserがなく、その呼出しで実API連携をtestしたとは言えません。定義のないclient/matcherの実行例を取り下げ、実装した上の読取・validationに対するlocal testへ限定しました。

toMatchSchemaはJest標準matcherではありません。必要な追加matcherを明示するか、導入済みのschema validatorで検証して通常のassertionへ渡します。testをskipしたことを成功と報告しません。

MSWのrestはv1形式です。v2はhttp/HttpResponse、v3移行資料では両者のimport元はmsw/httpです。利用版とNode/browserのsetupを照合し、unhandled requestを外部へ通さないtestを用意します。この訂正ではMSWをinstall・実行していません。

OpenAPIは有効な形式で記述する

旧code blockはjsonと表示しながらインデントの崩れたYAML風記述で、escaped $refも不正でした。以下は架空の仕様案を正しいJSONとして表した例です。

{
  "openapi": "3.0.3",
  "info": {
    "title": "架空ユーザーAPI",
    "version": "1.0.0"
  },
  "paths": {
    "/users/{id}": {
      "get": {
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        ],
        "responses": {
          "200": {
            "description": "成功",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "404": {
            "description": "対象なし"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "User": {
        "type": "object",
        "required": [
          "id",
          "name",
          "email"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "minimum": 1
          },
          "name": {
            "type": "string",
            "maxLength": 100
          },
          "email": {
            "type": "string",
            "maxLength": 254
          }
        }
      }
    }
  }
}

JSONをparseし、path parameterがrequiredであることとref先が存在することを確認しました。OpenAPI 3.0.3仕様の全体schema検証、生成client、実endpointとのcontract testは未実施です。emailの全形式条件や認証・error body等もこの仕様案で網羅したものではありません。

直接連携・BFF・gateway・eventを選ぶ前提

秘密を扱うならserver側の責任を決めます。BFFを置くだけで安全にはなりません。認証・認可、許可endpoint、入力・response上限、rate limit、ログと秘密のmask、cacheのuser/tenant境界、通信失敗を確認します。CORSはbrowserの読取制御であり、APIへの認証・認可の代替ではありません。

複数APIをeventでつなぐ場合も、重複・順序・部分成功・補償・人間確認を設計します。旧稿の決済・在庫・SNS連携の図は一般的な設計例で、今回実装した顧客案件や送信成果ではありません。

確認範囲

公式security・API・MSW・OpenAPI資料、20項目のローカルtest、JSON構文とrefを確認しました。OAuth flow、DOM表示、BFF、実provider、MSW、生成client、本番障害・費用・成果は未検証です。

この記事を書いた人✏️@YushiYamamoto
ITPRODX.com代表 / AIアーキテクト
Next.js / TypeScript / n8nを活用した自律型アーキテクチャ設計を専門としています。
日々の自動化の検証結果や、ビジネス側の視点(ROI等)に関するより深い考察は、以下の公式サイトおよびnoteで発信しています。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?