ルートパラメーター
前章のエンドポイントは固定 URL にしか応答できませんでした。実際の API では URL にデータを含めることがよくあります。/users/42 の 42 はユーザー ID で、/files/docs/report.pdf の後半はファイルパスです。この節の新しい概念はルートパラメーター(route parameter)です。URL の一部を「空欄」にし、その値をハンドラーの引数として受け取ります。
この節の完成コードです。ハイライト箇所は前章から追加された部分です。
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapGet("/users/{id:int}", (int id) => new { Id = id, Name = $"User {id}" });
app.MapGet("/users/me", () => new { Id = 0, Name = "Current user" });
app.MapGet("/files/{*path}", (string path) => new { Path = path });
app.Run();1~13 行目は前章と同じため、繰り返し説明しません。
実行と確認
前章と同じ方法でプロジェクトを作成するか、リポジトリの samples/03-path-params ディレクトリに移動して実行します。
dotnet run次のリクエストを順に送り、結果を確認してください。
curl http://localhost:5080/users/42{"id":42,"name":"User 42"}curl http://localhost:5080/users/me{"id":0,"name":"Current user"}curl http://localhost:5080/files/docs/2026/report.pdf{"path":"docs/2026/report.pdf"}「不正な」パスも試し、-i でステータスコードを確認します。
curl -i http://localhost:5080/users/abcHTTP/1.1 404 Not Found
Content-Length: 0
Date: Sat, 26 Sep 2026 08:55:56 GMT
Server: Kestrel四つの結果が確認できたら、それぞれの仕組みを見ていきましょう。
ルートパラメーターを宣言する
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapGet("/users/{id:int}", (int id) => new { Id = id, Name = $"User {id}" });
app.MapGet("/users/me", () => new { Id = 0, Name = "Current user" });
app.MapGet("/files/{*path}", (string path) => new { Path = path });
app.Run();ルートテンプレート "/users/{id:int}" の波括弧で囲んだ部分がルートパラメーターです。ハンドラー引数の int id と同じ名前です。フレームワークはこの名前で対応付けます。/users/42 へのリクエストでは 42 を取り出し、引数 id に渡します。
この処理をパラメーターバインド(parameter binding)と呼びます。規約に基づいて行われるため、「id はルートから来る」と注釈で示す必要はありません。名前が一致していれば十分です。
文字列が自動で int に変換される
URL は実質的に文字列ですが、ハンドラーが受け取る id は int です。変換はフレームワークが行い、int.TryParse を使って "42" を 42 にします。
そのため、ハンドラー内では id は整数そのものとして扱え、コンパイラーも整数として判断します。
id + 1は43になり、文字列連結した"421"にはなりません。id.Lengthは整数に長さがないため、コンパイルエラーになります。
error CS1061: “int”未包含“Length”的定义,并且找不到可接受第一个“int”类型参数的可访问扩展方法“Length”(是否缺少 using 指令或程序集引用?)引数に書いた型は、入力形式の宣言でもあります。後続のコードでは整数として安全に扱え、自分で解析や判定をする必要はありません。int のほか、long、Guid、DateTime、bool、列挙型なども引数の型として直接使えます。
FastAPI との比較
FastAPI の def get_user(id: int) とほぼ同じ効果です。型注釈が変換規則を決めます。違いは、C# では型がコンパイル時に検査され、誤った型の使い方はコンパイルを通らないことです。
型はドキュメントにも反映される
http://localhost:5080/openapi/v1.json を開き、/users/{id} の箇所を探します。
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": "integer",
"format": "int32"
}
}
]"type": "integer" と "format": "int32" はコードの int から取得されています。/scalar ページでこのエンドポイントを試すと、整数を入力するよう案内されます。一つの型宣言で、変換規則、コンパイル時チェック、API ドキュメントが決まります。
ルート制約
テンプレート {id:int} の :int の部分をルート制約(route constraint)と呼びます。役割は、この部分が整数として解析できるときだけ、そのエンドポイントを一致候補にすることです。
冒頭の /users/abc が 404 Not Found になったのはこのためです。abc は int 制約を満たさず、このエンドポイントは選ばれません。フレームワークは処理できるエンドポイントを見つけられません。
ハンドラーの引数がすでに int なのに、なぜテンプレートにも制約を書くのでしょうか。制約を外して比較してみます。テンプレートを "/users/{id}" にすると、/users/abc は次の応答になります。
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to bind parameter "int id" from "abc".違いは検査されるタイミングです。
| 発生するタイミング | 失敗時 | 意味 | |
|---|---|---|---|
ルート制約 {id:int} | エンドポイントの一致前 | 404 | 「この URL はこのエンドポイントに一致しない」 |
引数型 int id | エンドポイントの一致後 | 400 | 「エンドポイントには一致したが、引数値を変換できない」 |
どちらを使えばよいでしょうか。Microsoft 公式の推奨は明確です。ルート制約は似た形のルートを区別するために使い、入力検証には使いません。クライアントの視点では /users/abc は「引数の指定ミス」です。説明のない 404 より、エラーの説明を伴う 400 の方が有用です。
制約が本当に役立つのは、同じ場所に複数のエンドポイントを置く場合です。たとえば /users/{id:int}(ID で検索)と /users/{name}(ユーザー名で検索)を用意すると、/users/42 は int 制約を満たすため前者に、/users/alice は制約を満たさないため後者に一致します。制約がなければこの二つのルートは共存できません(次の節でその結果を確認します)。
この節の例では、制約の動作を観察するため :int を残しています。自分のプロジェクトで /users/{id} に同じ形の別ルートがなければ、制約を外して不正値を 400 にする方が呼び出し側に親切です。
ヒント
/users/99999999999 も 404 になります。この数値は int の範囲(約 ±21 億)を超え、int 制約を満たさないためです。ID がもっと大きくなる可能性があるなら、long 型と {id:long} 制約を使ってください。
よく使う制約は次のとおりです。
| 制約 | 例 | 一致する値 |
|---|---|---|
int / long | {id:int} | 整数 |
guid | {id:guid} | 0f8fad5b-d9cb-469f-a165-70867728950e のような GUID |
bool | {on:bool} | true または false |
alpha | {name:alpha} | 英字のみ |
min(1) | {page:min(1)} | 1 以上の整数 |
minlength(3) | {code:minlength(3)} | 3 文字以上 |
複数の制約を連結できます。例:{id:int:min(1)}。
注意
ルート制約を入力検証に使わないでください。制約に失敗すると 404 が返り、クライアントは「アドレスが存在しない」ことしか分からず、何が誤っているか分かりません。制約の役割はルートを区別することです。「形式は正しいが値が不正」(たとえば年齢が負数)は入力検証の対象で、400 と明確なエラーを返すべきです。「入力検証」の章で扱います。
ルートの優先順位
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapGet("/users/{id:int}", (int id) => new { Id = id, Name = $"User {id}" });
app.MapGet("/users/me", () => new { Id = 0, Name = "Current user" });
app.MapGet("/files/{*path}", (string path) => new { Path = path });
app.Run();登録順に注目してください。15 行目の /users/{id:int} は、17 行目の /users/me より前にあります。しかし /users/me をリクエストすると、17 行目が選ばれます。
これは登録順と関係ありません。:int 制約を外し、/users/{id} が形式上 me にも一致できるようにしても、/users/me は引き続き 17 行目に一致します(実際に試せます)。ASP.NET Core のルーティングは登録順に候補を試す仕組みではありません。一致する候補をすべて見つけた後、優先順位に従って最も具体的なものを選びます。大まかな順序は次のとおりです。
meのようなリテラルセグメントが最優先。- 次に
{id:int}のような制約付きパラメーター。 - 次に
{id}のような制約なしパラメーター。 - 最後に
{*path}のような catch-all パラメーター(次の節で説明)。
この設計の理由は何でしょうか。 実際のプロジェクトでは、エンドポイントは複数のファイルに分けて登録されることが多く(「大きなプロジェクトの構成」の章を参照)、登録順を制御するのは困難であり、プログラムの動作が順序に左右されるべきでもありません。優先順位による一致なら、どの順で登録しても結果は変わりません。
FastAPI との比較
これは FastAPI と大きく異なる点です。FastAPI は宣言順にルートを照合するため、/users/me は /users/{user_id} より前に書く必要があります。ASP.NET Core では順序に依存しません。
リクエストが複数のルートに一致し、優先順位もまったく同じ場合はどうなるでしょうか。たとえば /users/{id} と /users/{name}(どちらも制約なし)を登録すると、/users/42 に対してフレームワークは選択できず、AmbiguousMatchException(曖昧一致例外)を伴う 500 エラーになります。
競合するには、同じリクエストに一致することが前提です。/users/{id} と /posts/{id} は優先順位が同じでも、同じ URL に一致することはないため問題ありません。
上記の競合はコンパイル時に警告されます。
warning ASP0022: Route '/users/{id}' conflicts with another handler route. An HTTP request that matches multiple routes results in an ambiguous match error.これは ASP.NET Core に組み込まれたアナライザー(analyzer)がコードを検査しているためです。C# の構文だけでなく、ルートテンプレートの意味も理解しています。
注意
「コンパイル時に警告がなかった」ことを「ルートに競合がない」ことと同じだと考えないでください。誤検出を避けるため、ASP0022 は慎重に設計されており、同じコードブロック内の重複ルートだけを検査します。たとえば if の別々の分岐に書かれた競合ルートは報告されません。アナライザーはよくあるミスを見つける助けになりますが、テストの代わりにはなりません。
Catch-all パラメーター
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapGet("/users/{id:int}", (int id) => new { Id = id, Name = $"User {id}" });
app.MapGet("/users/me", () => new { Id = 0, Name = "Current user" });
app.MapGet("/files/{*path}", (string path) => new { Path = path });
app.Run();{*path} はcatch-all パラメーターで、その位置以降のパス全体を取り込みます。/files/docs/2026/report.pdf なら path の値は docs/2026/report.pdf です。
通常のルートパラメーターは、二つの / の間にある一つのセグメントだけに一致します。名前の前に * を付けるとcatch-all パラメーター(catch-all parameter)になり、残りのすべてのセグメントを、途中の / も含めて引数に渡します。
そのため /files/docs/2026/report.pdf へのリクエストでは path の値は docs/2026/report.pdf です。ファイルパスや階層数が一定しないカテゴリなどを表すのに適しています。catch-all パラメーターはテンプレートの最後のセグメントにしか置けません。
注意
path の型は string(null 不可)なので、引数は必須です。/files/(後ろに何もない)をリクエストすると 400 エラーになります。省略を許可するには、引数の型を string? に変更します。疑問符は「null を許可する」という意味で、この場合 path は null を受け取ります。nullable 型についてはC# の概要を参照してください。
FastAPI との比較
{*path} は FastAPI の {file_path:path} パスコンバーターに相当します。
名前が一致しない場合
パラメーターの対応付けに名前を使うなら、名前を間違えるとどうなるでしょうか。テンプレートが "/users/{id}" で、ハンドラーの引数を (int userId) と書いた場合、コンパイルは通りますが、/users/5 のリクエストは次のようになります。
HTTP/1.1 400 Bad Request
Microsoft.AspNetCore.Http.BadHttpRequestException: Required parameter "int userId" was not provided from query string.エラーメッセージの末尾に注目してください。フレームワークは userId をクエリ文字列(query string)から探しています。ルートテンプレートに userId という名前のパラメーターがないため、URL の ? 以降から取得すると推論したのです。
これが次章のテーマにつながります。今は、ルートテンプレート内の名前と引数名を一致させると覚えておきましょう。
まとめ
- ルートテンプレートで
{name}とルートパラメーターを宣言すると、ハンドラーの同名引数がその値を自動で受け取ります。 intなどの引数型が文字列からの変換規則を決めます。変換後の値はコンパイル時にその型で検査され、OpenAPI ドキュメントにも自動で記載されます。{id:int}などのルート制約はエンドポイントの一致前に働き、失敗すると 404 です。引数の型変換は一致後に行われ、失敗すると 400 です。制約はルートを区別するために使い、入力検証には使いません。- ルートは登録順ではなく優先順位で一致します。リテラル > 制約付きパラメーター > 通常のパラメーター > catch-all の順です。同じリクエストに同じ優先順位のルートが一致すると曖昧一致になり、分析器 ASP0022 はその一部をコンパイル時に検出します。
{*path}はcatch-all パラメーターで、/を含む残りのパスに一致します。
