Azure API ManagementでOpenAPIの必須クエリがテンプレート化され404になる問題の原因と対処(PolicyとIaCの実装完全ガイド)

OpenAPI の必須クエリパラメーターが Azure API Management (APIM) でテンプレートパラメーター化され、欠落時に 404 Not Found になってしまう——現場でよく詰まるポイントです。本記事では、なぜそうなるのかの背景から、最小のポリシー実装、API/グローバル共通化、IaC(Bicep/Terraform/ARM)での適用、自動テストまでを一気通貫で解説します。貼り付けて使える XML ポリシー断片も多数掲載します。


目次

APIM 取り込み時に「必須」クエリがテンプレート化される現象の理解

OpenAPI では parameters[].in: query に対して required: true を付けることで、クライアントに「必須」であることを伝えます。一方 APIM は、インポート時のルーティング最適化として、必須クエリをテンプレートパラメーター(ルート解決対象)へ昇格させることがあります。これにより該当クエリが存在しないリクエストは「ルートにマッチしない」と判定され、APIM からは 404 が返ります。本来、クライアントは 400(Bad Request)を期待しているのに、ゲートウェイで 404 が返るため実装/監視の両面で不整合が生じます。

最小再現例(OpenAPI v3, 抜粋)

paths:
  /items:
    get:
      parameters:
        - name: id
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          description: OK

上記を APIM にインポートすると、id が欠落した GET /items に対して APIM が 404 を返し得ます。これが本記事で扱う現象です。

解決の基本方針:Policy で 404 → 400 を明示的に補正する

APIM のルーティング挙動自体はコントロールできないため、ポリシー(Policy)で入力検証を行い、要件を満たさない場合は 400 を返すのが堅実かつ一般的な対処です。Operation 単位/API 単位/グローバル(全 API)単位のいずれにも適用できます。

個別 Operation での最小実装

Azure ポータルでも IaC でも設定方法は同じです。対象 Operation の <inbound> に次の断片を追加します。

&lt;choose&gt;
  &lt;when condition="@(
    string.IsNullOrEmpty(
      context.Request.OriginalUrl.Query.GetValueOrDefault(&quot;id&quot;)
    )
  )"&gt;
    &lt;return-response&gt;
      &lt;set-status code="400" reason="Bad Request" /&gt;
      &lt;set-header name="Content-Type" exists-action="override"&gt;
        &lt;value&gt;application/json&lt;/value&gt;
      &lt;/set-header&gt;
      &lt;set-body&gt;{
        "error": "必須クエリ 'id' がありません。"
      }&lt;/set-body&gt;
    &lt;/return-response&gt;
  &lt;/when&gt;
&lt;/choose&gt;
  • id を実際の必須クエリ名に置き換えてください。
  • 複数ある場合は <when> ブロックを複数追加するか、後述の「共通化」案を使うと保守が容易です。

戻り値を 400 にする意義

  • クライアント実装の一貫性:バリデーションエラーは 400 として扱うのが一般的で、クライアント側のリトライ/エラーハンドリングが正しく機能します。
  • 監視/分析の正確性:404 はルーティング/URL ミス、400 は入力エラーとしてメトリックを分離でき、SLO/SLA 分析が鮮明になります。

多数のパラメーターをまとめて検証する(API/グローバル共通化)

API 全体や複数 Operation に跨って必須クエリが多数ある場合、共通化して 保守コストを劇的に下げるのがポイントです。APIM Policy は C# ベースの式を使えるため、配列で一覧を持ち、欠落している最初の要素を検出するロジックを 1 箇所に集約できます。

API スコープでの共通ポリシー例

<inbound>
  <base />





string.IsNullOrEmpty(context.Request.OriginalUrl.Query.GetValueOrDefault(n))
);
}" />






application/json

{
"error": "必須クエリが不足しています。",
"missing": "@((string)context.Variables["missingQueryParam"])"
}





 

Operation ごとに必須項目が異なるなら、次のようにルートで分岐します。

&lt;set-variable name="route" value="@((string)context.Request.OriginalUrl.Path)" /&gt;
&lt;choose&gt;
  &lt;when condition="@(((string)context.Variables[&quot;route&quot;]).StartsWith(&quot;/items&quot;))"&gt;
    &lt;set-variable name="requiredQueryParams" value="@(new [] { &quot;id&quot; })" /&gt;
  &lt;/when&gt;
  &lt;otherwise&gt;
    &lt;set-variable name="requiredQueryParams" value="@(new string[0])" /&gt;
  &lt;/otherwise&gt;
&lt;/choose&gt;

Policy フラグメントでの再利用

APIM の「ポリシーフラグメント」を使うと、検証ロジックを 1 箇所に定義して複数 API から参照できます。

<!-- フラグメント: require-query-params -->
<set-variable name="missingQueryParam" value="@{
  var required = (string[])context.Variables.GetValueOrDefault("requiredQueryParams") ?? new string[0];
  return required.FirstOrDefault(n => string.IsNullOrEmpty(context.Request.OriginalUrl.Query.GetValueOrDefault(n)));
}" />






application/json

{
"error":"必須クエリが不足しています",
"missing":"@((string)context.Variables["missingQueryParam"])"
}


 

呼び出し側(API/Operation)では、必須一覧だけ与えてフラグメントを <include-fragment> します。

&lt;set-variable name="requiredQueryParams" value="@(new [] { &quot;id&quot;, &quot;tenantId&quot; })" /&gt;
&lt;include-fragment fragment-id="require-query-params" /&gt;

入力値の型・範囲チェックもまとめてやる

「存在チェック」だけでなく、型や範囲もゲートで早期に弾きましょう。例として、page は整数で 1 以上、size は 1〜100 の範囲に制約します。

<set-variable name="pageStr" value="@(context.Request.OriginalUrl.Query.GetValueOrDefault("page"))" />
<set-variable name="sizeStr" value="@(context.Request.OriginalUrl.Query.GetValueOrDefault("size"))" />

= 1;
}" />
= 1 && v <= 100);
}" />





application/json
{"error":"page は 1 以上の整数で指定してください"}





application/json
{"error":"size は 1〜100 の整数で指定してください"}


 

IaC での適用:Bicep / Terraform / ARM

Bicep(Operation ポリシー)

resource op 'Microsoft.ApiManagement/service/apis/operations@2022-08-01' existing = {
  name: '${apim.name}/${api.name}/get-items'
}

resource opPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2022-08-01' = {
name: '${op.name}/policy'
parent: op
properties: {
format: 'rawxml'
value: '''







application/json
{"error":"必須クエリ 'id' がありません"}








'''
}
} 

Terraform(Operation ポリシー)

resource "azurerm_api_management_api_operation_policy" "items_get_policy" {
  api_management_name = azurerm_api_management.apim.name
  resource_group_name = azurerm_resource_group.rg.name
  api_name            = azurerm_api_management_api.api.name
  operation_id        = azurerm_api_management_api_operation.items_get.operation_id
  xml_content         = &lt;&lt;POLICY
&lt;policies&gt;
  &lt;inbound&gt;
    &lt;base /&gt;
    &lt;choose&gt;
      &lt;when condition="@(
        string.IsNullOrEmpty(
          context.Request.OriginalUrl.Query.GetValueOrDefault(&quot;id&quot;)
        )
      )"&gt;
        &lt;return-response&gt;
          &lt;set-status code=&quot;400&quot; reason=&quot;Bad Request&quot; /&gt;
          &lt;set-header name=&quot;Content-Type&quot; exists-action=&quot;override&quot;&gt;&lt;value&gt;application/json&lt;/value&gt;&lt;/set-header&gt;
          &lt;set-body&gt;{&quot;error&quot;:&quot;必須クエリ 'id' がありません&quot;}&lt;/set-body&gt;
        &lt;/return-response&gt;
      &lt;/when&gt;
    &lt;/choose&gt;
  &lt;/inbound&gt;
  &lt;backend /&gt;
  &lt;outbound /&gt;
  &lt;on-error /&gt;
&lt;/policies&gt;
POLICY
}

ARM(API スコープ)

{
  "type": "Microsoft.ApiManagement/service/apis/policies",
  "apiVersion": "2022-08-01",
  "name": "[concat(parameters('apimName'), '/', parameters('apiName'), '/policy')]",
  "properties": {
    "format": "rawxml",
    "value": "&lt;policies&gt;&lt;inbound&gt;&lt;base /&gt;&lt;set-variable name='requiredQueryParams' value='@(new [] { &quot;id&quot; })' /&gt;&lt;include-fragment fragment-id='require-query-params' /&gt;&lt;/inbound&gt;&lt;backend /&gt;&lt;outbound /&gt;&lt;on-error /&gt;&lt;/policies&gt;"
  }
}

パイプラインに組み込むヒント

  • OpenAPI インポート直後に「検証ポリシー差し込み」ステップを実行(フラグメントを事前発行 → API/Operation に適用)。
  • 必須クエリ一覧は CI の変数ファイル(JSON/YAML)化してサービス/バージョンごとに管理。

エラーレスポンス設計(例)

クライアント開発者がデバッグしやすいよう、エラーコードと具体的な欠落項目を返しましょう。

{
  "error": {
    "code": "MissingQueryParameter",
    "message": "必須クエリが不足しています。",
    "details": [
      { "name": "id", "reason": "required" }
    ],
    "correlationId": "{@(context.RequestId)}"
  }
}

APIM のポリシー式で context.RequestId を挿入しておくと、ゲートウェイの診断ログとの突合が容易になります。

挙動比較:放置 vs Policy 補正

観点放置(テンプレート化のまま)Policy で 400 に補正(推奨)
欠落リクエストのステータス404 Not Found400 Bad Request
クライアントの再送ロジック404 と誤認しルーティング調査に工数入力エラーとして即時修正に誘導
監視/アラートの精度URL ミスと入力ミスが混在原因別に切り分け可能
運用コスト問い合わせ増加・トリアージ負荷エラー内容が明確で自己解決率が上がる

よくある落とし穴と回避策

  • 「required を外す」対処は非推奨:OpenAPI をアプリから自動生成しているならスキーマ真実性を下げます。APIM で補正しましょう。
  • Operation 毎にポリシーを複製しすぎる:フラグメント化や API スコープでの共通化が効果的。必須一覧だけ差し替える構造に。
  • 型/範囲の検証漏れ:存在チェックだけでなく整数/列挙/正規表現もゲートで早期に弾くとバックエンド負荷を下げられます。
  • Content-Type 未指定:JSON を返すなら Content-Type: application/json を必ず明示。
  • 多言語対応:運用チーム向けの英語メッセージと、クライアント向けの日本語メッセージを切り替えたい場合は Named Value やヘッダーで分岐。

リクエスト例とテストレシピ

cURL

# 欠落: id なし(期待 = 400)
curl -i "https://<gateway>/items"

# 正常: id あり(期待 = 200)

curl -i "https:///items?id=abc123" 

Postman/Newman での自動化ポイント

  • シナリオ 1:必須クエリ欠落 → pm.response.code === 400 をアサート。
  • シナリオ 2:異常型(例:page=a) → 400 と精緻なメッセージをアサート。
  • シナリオ 3:境界値(例:size=0 / size=101)→ 400。

高度な設計:Operation ごとに必須クエリを宣言的に渡す

「宣言(必須一覧)+共通バリデーション」の 2 層に分けると、規模が増えても崩れません。下記は各 Operation の頭で変数だけ設定し、直後にフラグメントを呼ぶ構成です。

<inbound>
  <base />
  <!-- この Operation は id, tenantId が必須 -->
  <set-variable name="requiredQueryParams" value="@(new [] { "id", "tenantId" })" />
  <include-fragment fragment-id="require-query-params" />



 

新規 Operation を追加しても、宣言するだけで同じ品質のエラーハンドリングを自動適用できます。

JSON スキーマの検証と組み合わせる

ボディが JSON の場合は ボディはスキーマ検証、クエリは本記事の方式という二段構えにしましょう。バリデーションのレイヤが明確になり、バグ探索が容易です。

監視・診断のコツ

  • 失敗率の分離:ゲートで 400 に倒すと、バックエンド 4xx/5xx から独立して可視化できます。
  • 相関 ID:context.RequestId をエラー JSON に含め、APIM のログ/トレースと突合。
  • カスタムヘッダー:X-Error-Code を付与するとフロントの可観測性が向上。
&lt;set-header name="X-Error-Code" exists-action="override"&gt;
  &lt;value&gt;MISSING_QUERY_ID&lt;/value&gt;
&lt;/set-header&gt;

セキュリティ観点の注意

  • エラー詳細に秘匿情報を含めない(例:内部キー名、実 DB カラム名など)。
  • 列挙型の値域は、存在チェックだけでなくホワイトリストで検証。
  • リクエストサイズ上限やレート制限と併用し、DoS 耐性を確保。

アンチパターン/代案比較

アプローチメリットデメリット適用可否
Policy で 400 化(推奨)低コスト・即効性・既存スキーマを保てる必須一覧のメンテが必要(共通化で軽減)◎
OpenAPI の required を外すAPIM の 404 問題は回避スキーマの正確性が落ち、SDK/Doc に悪影響×(非推奨)
ルート書き換え(rewrite-uri)で回避一見 404 を避けられる欠落を見逃してバックエンドに到達しがち△(限定的)
将来の拡張/設定に期待保守性は高い可能性現時点の実務課題は解けない—

FAQ

Q. OpenAPI v2(Swagger)でも起きますか?

取り込み時のルーティング最適化は仕様依存で、クエリ必須 → ルート要素化という本質は v2/v3 いずれでも起こり得ます。発生する前提でポリシー側で担保する方が安全です。

Q. Developer Portal の「試してみる」で 404 になります。

同じ原因です。Portal の動作は APIM 経由なので、欠落時に 404 が出る場合はポリシー補正を入れて 400 に揃えましょう。

Q. 必須だが空文字は可、という要件は?

「存在チェック」は pass、「空は可」なら IsNullOrEmpty ではなく GetValueOrDefault(..., string.Empty) 等で null だけを弾くように条件を書き分けます(空文字可の基準を合意してから実装してください)。

Q. バックエンドでも検証すべき?

はい。ゲートで早期弾き+アプリで最終バリデーションの二重化が堅牢です。ゲートは軽量な否定判定、アプリはドメインロジックの精密判定を担当します。

まとめ:運用に強いチェックリスト

  • 必須クエリは APIM で 404 になり得ると理解する。
  • Operation から開始し、API/フラグメントへ共通化して保守性を高める。
  • 存在だけでなく型/範囲/列挙も検証、明確な 400 を返す。
  • エラー JSON に相関 ID・エラーコードを付けて可観測性を上げる。
  • CI/CD に「ポリシー差し込み」ステップを入れて人手の揺れを無くす。

以上で、OpenAPI の「必須」クエリが APIM でテンプレートパラメーター化されることによる 404 を、一貫して 400 に正規化する実装指針を網羅しました。掲載のポリシー断片をそのまま貼り付けつつ、フラグメント化と IaC を併用することで、規模が大きくなっても運用コストを最小化できます。


付録:複数パラメーターの宣言+検証を 1 ブロックに纏めた完全版

<policies>
  <inbound>
    <base />
&lt;!-- Operation ごとの宣言。ここだけ編集すればよい --&gt;
&lt;set-variable name="requiredQueryParams" value="@(new [] { &quot;id&quot;, &quot;tenantId&quot;, &quot;locale&quot; })" /&gt;

&lt;!-- 存在チェック --&gt;
&lt;set-variable name="missingQueryParam" value="@{
  var req = (string[])context.Variables[&quot;requiredQueryParams&quot;];
  return req.FirstOrDefault(n =&gt; string.IsNullOrEmpty(context.Request.OriginalUrl.Query.GetValueOrDefault(n)));
}" /&gt;

&lt;choose&gt;
  &lt;when condition="@(!string.IsNullOrEmpty((string)context.Variables[&quot;missingQueryParam&quot;]))"&gt;
    &lt;return-response&gt;
      &lt;set-status code="400" reason="Bad Request" /&gt;
      &lt;set-header name="Content-Type" exists-action="override"&gt;&lt;value&gt;application/json&lt;/value&gt;&lt;/set-header&gt;
      &lt;set-body&gt;{
        "error":"必須クエリが不足しています",
        "missing":"@((string)context.Variables[&quot;missingQueryParam&quot;])",
        "correlationId":"@((string)context.RequestId)"
      }&lt;/set-body&gt;
    &lt;/return-response&gt;
  &lt;/when&gt;
&lt;/choose&gt;

&lt;!-- 型/範囲チェックの例: locale は ja|en のみ --&gt;
&lt;set-variable name="locale" value="@(context.Request.OriginalUrl.Query.GetValueOrDefault(&quot;locale&quot;))" /&gt;
&lt;choose&gt;
  &lt;when condition="@{
    var lc = (string)context.Variables[&quot;locale&quot;];
    return !(lc == &quot;ja&quot; || lc == &quot;en&quot;);
  }"&gt;
    &lt;return-response&gt;
      &lt;set-status code="400" reason="Bad Request" /&gt;
      &lt;set-header name="Content-Type" exists-action="override"&gt;&lt;value&gt;application/json&lt;/value&gt;&lt;/set-header&gt;
      &lt;set-body&gt;{"error":"locale は ja / en のいずれか"}&lt;/set-body&gt;
    &lt;/return-response&gt;
  &lt;/when&gt;
&lt;/choose&gt;





 

付録:運用チェックリスト(貼って使える)

  • OpenAPI の必須クエリ一覧を棚卸し済みか。
  • API/Operation で requiredQueryParams を宣言し、フラグメントを適用しているか。
  • エラー JSON に correlationId と X-Error-Code を含めているか。
  • 整数・範囲・列挙の型検証をゲートで実施しているか。
  • CI/CD の「ポリシー差し込み」ステップが安定稼働しているか。
  • 404/400 のメトリックを分離し、ダッシュボードで可視化しているか。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次