本文へスキップ

ルートグループ ​

エンドポイントが増えると、同じ種類のエンドポイントに重複が多いことに気づきます。パスがすべて /api/todos で始まる、今後ログイン必須にする、ドキュメント上でも同じカテゴリにまとめる、などです。この節の新しい概念はルートグループ(route group)で、共通点を一か所にまとめられます。

10-route-groups/Program.cs
cs
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

List<Todo> todos = [new(1, "Buy milk", false)];
var nextId = 2;

var api = app.MapGroup("/api");

var todosApi = api.MapGroup("/todos").WithTags("Todos");

todosApi.MapGet("/", () => todos);

todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id) =>
{
    var todo = todos.Find(t => t.Id == id);
    return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
});

todosApi.MapPost("/", Created<Todo> (CreateTodo input) =>
{
    var todo = new Todo(nextId++, input.Title, Done: false);
    todos.Add(todo);
    return TypedResults.Created($"/api/todos/{todo.Id}", todo);
});

todosApi.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id) =>
    todos.RemoveAll(t => t.Id == id) > 0 ? TypedResults.NoContent() : TypedResults.NotFound());

api.MapGet("/health", () => new { Status = "ok" }).WithTags("System");

app.Run();

record CreateTodo(string Title);

record Todo(int Id, string Title, bool Done);

この章では第 08 章の CRUD の例を基にルートグループを説明します。第 06 章の入力検証と第 09 章の共通エラー処理はいったん省き、エンドポイントの登録先の変化に集中します。これらの機能は MapGroup と組み合わせられます。省略しているため、この章でリソースが見つからない場合の 404 レスポンスには本文がありません。

実行と確認 ​

bash
dotnet run

Todo のエンドポイントはすべて /api/todos の下になります。

bash
curl http://localhost:5080/api/todos
json
[{"id":1,"title":"Buy milk","done":false}]
bash
curl http://localhost:5080/api/todos/1
json
{"id":1,"title":"Buy milk","done":false}
bash
curl -i -X POST http://localhost:5080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Write report"}'
http
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/todos/2

{"id":2,"title":"Write report","done":false}

ヘルスチェックのエンドポイントは /api/health です。

bash
curl http://localhost:5080/api/health
json
{"status":"ok"}

古いアドレス /todos はもう存在せず、404 を返します。

MapGroup でグループを作る ​

10-route-groups/Program.cs
cs
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

List<Todo> todos = [new(1, "Buy milk", false)];
var nextId = 2;

var api = app.MapGroup("/api");

var todosApi = api.MapGroup("/todos").WithTags("Todos");

todosApi.MapGet("/", () => todos);

todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id) =>
{
    var todo = todos.Find(t => t.Id == id);
    return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
});

todosApi.MapPost("/", Created<Todo> (CreateTodo input) =>
{
    var todo = new Todo(nextId++, input.Title, Done: false);
    todos.Add(todo);
    return TypedResults.Created($"/api/todos/{todo.Id}", todo);
});

todosApi.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id) =>
    todos.RemoveAll(t => t.Id == id) > 0 ? TypedResults.NoContent() : TypedResults.NotFound());

api.MapGet("/health", () => new { Status = "ok" }).WithTags("System");

app.Run();

record CreateTodo(string Title);

record Todo(int Id, string Title, bool Done);

19 行目の app.MapGroup("/api") はグループを作成します。戻り値の api は「/api で始まるすべてのルート」を表します。

21 行目では api にさらに MapGroup("/todos") を呼び、入れ子グループを作成します。完全なプレフィックスは /api/todos です。

その後 todosApi で MapGet や MapPost を呼ぶときは、相対パスを記述します。

登録コード実際のルート
todosApi.MapGet("/", ...)GET /api/todos
todosApi.MapGet("/{id:int}", ...)GET /api/todos/{id}
todosApi.MapPost("/", ...)POST /api/todos
todosApi.MapDelete("/{id:int}", ...)DELETE /api/todos/{id}
api.MapGet("/health", ...)GET /api/health

グループオブジェクトの使い方は app とほとんど同じで、MapGet、MapPost、MapGroup などを呼び出せます。これが便利な点です。新しい API を覚える必要はなく、呼び出す対象を変えるだけです。

各ルートに完全なパスを直接書かないのはなぜでしょうか。プレフィックスは一つの決定事項であり、一度だけ書くべきです。API を /api/v2 に変更する場合、グループを使えば 19 行目の一か所だけを直せば済みます。プレフィックスが各エンドポイントに散らばっていると、一つずつ変更しなければならず、修正漏れで URL が不統一になる可能性があります。

注意

35 行目の Created のアドレス $"/api/todos/{todo.Id}" は、完全なパスを引き続き手動で指定しています。グループのプレフィックスは自動でここに追加されません。プレフィックスを変更するときは、このアドレスも忘れずに変更してください。

グループにメタデータを追加する ​

10-route-groups/Program.cs
cs
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

List<Todo> todos = [new(1, "Buy milk", false)];
var nextId = 2;

var api = app.MapGroup("/api");

var todosApi = api.MapGroup("/todos").WithTags("Todos");

todosApi.MapGet("/", () => todos);

todosApi.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id) =>
{
    var todo = todos.Find(t => t.Id == id);
    return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
});

todosApi.MapPost("/", Created<Todo> (CreateTodo input) =>
{
    var todo = new Todo(nextId++, input.Title, Done: false);
    todos.Add(todo);
    return TypedResults.Created($"/api/todos/{todo.Id}", todo);
});

todosApi.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id) =>
    todos.RemoveAll(t => t.Id == id) > 0 ? TypedResults.NoContent() : TypedResults.NotFound());

api.MapGet("/health", () => new { Status = "ok" }).WithTags("System");

app.Run();

record CreateTodo(string Title);

record Todo(int Id, string Title, bool Done);

グループの役割はパスプレフィックスだけではありません。21 行目の .WithTags("Todos") は OpenAPI のタグをグループに加えます。グループ内のすべてのエンドポイントがタグを引き継ぎます。41 行目ではヘルスチェックのエンドポイントだけに「System」タグを追加しています。

/scalar ページを開くと、左側のエンドポイント一覧がタグごとに二つに分かれます。「Todos」には四つ、「System」には一つ表示されます。/openapi/v1.json では、四つの Todo エンドポイントの tags が ["Todos"]、/api/health が ["System"] になります。

技術詳細

前の章ではタグを設定していなかったため、Scalar はプロジェクト名(例:FirstSteps)をすべてのエンドポイントの既定タグとして使っていました。

WithTags のような呼び出しは、エンドポイントにメタデータ(metadata)を付けます。処理ロジックを変更せず、フレームワークの別の部分が読み取る「タグ」を付け加えます。グループに追加したメタデータは、そのグループ内のすべてのエンドポイントに適用されます。このためグループは設定を一括するのに適しています。後の章では、次のような設定をグループに追加します。

  • 「認可」の章:todosApi.RequireAuthorization() でグループ内のすべてのエンドポイントをログイン必須にします。
  • 「CORS」の章:エンドポイントのグループにクロスオリジンアクセスを許可します。

一度の設定でグループ全体に適用され、新しく追加したエンドポイントも自動で設定を引き継ぎます。追加を忘れてセキュリティホールが生まれるのを防げます。

FastAPI との比較

MapGroup は FastAPI の APIRouter(prefix="/todos", tags=["Todos"]) に相当します。FastAPI では最後に app.include_router() で router を登録する必要があります。ASP.NET Core のグループは app.MapGroup() で作成した時点でアプリに登録され、21 行目のようにさらに入れ子にできます。

まとめ ​

  • app.MapGroup("/prefix") でルートグループを作ります。グループに登録するエンドポイントは相対パスを使い、実際のルートにはプレフィックスが自動で追加されます。
  • グループは入れ子にできます。api.MapGroup("/todos") のプレフィックスは /api/todos です。
  • WithTags などのメタデータをグループに追加すると、その中のすべてのエンドポイントに適用されます。認可や CORS などもグループ単位で設定できます。
  • プレフィックスと共通設定を一度だけ書けば、変更時の漏れを防げます。ただし Created などで手書きした完全アドレスは自分で同期する必要があります。

この章は「リクエストとレスポンス」段階の最後です。これで、引数を完全に扱い、検証を行い、適切なレスポンスを返し、構造を整理した API を作れるようになりました。次の「アプリケーションの骨格」段階は依存性注入から始め、例のメモリ内リストを実際のサービスに置き換えます。前の章:ステータスコードとエラー処理。

.NET 10 と Minimal API を使用 · 各章に実行可能なサンプルを用意