【.NET 9対応】ASP.NET Core Web APIの[Route]属性でGetDataが404になる原因と解決策まとめ

ASP.NET Core(.NET 9)で「コントローラにもアクションにも [Route] を付けたら、一方だけ 404 になる」という相談はとても多い問題です。原因は難解な実装ではなく、属性ルーティングの“連結”ルールを誤解していることが大半。この記事では、再現→原因→正しい書き方→デバッグ手順→落とし穴回避までを一気に整理します。貼り付けてすぐ試せるコードと表で、現場のつまずきを解消しましょう。

目次

症状の再現:/api/content は通るのに /api/content/GetData が 404

まずはよくあるコード例から。

using Microsoft.AspNetCore.Mvc;

namespace Demo.Controllers;

[Route("api/[controller]")]
[ApiController]
public class ContentController : ControllerBase
{
    [HttpGet] // → /api/content は 200 OK
    public string Get() => "OK";

    [HttpGet]
    [Route("api/Content/GetData/")] // → /api/content/GetData は 404
    public string GetData() => "OK";
}

上のコードでは、コントローラに [Route("api/[controller]")] があり、アクションにも [Route("api/Content/GetData/")] を付けています。結果として、

  • GET /api/content … 成功(200)
  • GET /api/content/GetData … 失敗(404)

なぜこの差が生まれるのでしょうか?

根本原因:属性ルーティングは「ベース+相対」の連結で解決される

ASP.NET Core MVC/Web API の属性ルーティングは以下のルールで解釈されます。

  • コントローラの [Route] がベースパスになる(例:api/[controller])。
  • アクションの [Route] は通常「相対パス」としてベースに連結される。
  • アクション側を絶対パス(= 連結しない)にしたい場合は、テンプレートを 「/」または「~/」で始める。

つまり、先の NG 例は実際にはこう展開されています。

// Controller: api/[controller] → api/content
// Action:    api/Content/GetData/(相対)
// 結果:      api/content + api/content/getdata → /api/content/api/content/getdata

開発者は「/api/content/getdata にしたい」つもりでも、フレームワークは「ベース+相対」で /api/content/api/content/getdata を作ってしまう──ここで URL がズレるため 404 になります。

補足として、ルーティングは既定で ASCII の大小を区別しないため、/api/Content と /api/content の表記ゆれ自体は直接の原因ではありません(リンク生成や統一運用の観点で小文字に揃えるのは推奨)。

正しい書き方は 3 通り(目的別)

以下のいずれかで解決します。最もシンプルなのは相対パスのみを書く方法です。

解決策実装例期待される URLポイント
① 相対パスのみを書く(推奨)[Route("api/[controller]")] public class ContentController : ControllerBase { [HttpGet("GetData")] public string GetData() => "OK"; }/api/content/getdataアクションのテンプレートはベースに連結される。
同じセグメント(api/content)を重ね書きしない。
② 絶対パスで書く[Route("api/[controller]")] public class ContentController : ControllerBase { [HttpGet] [Route("/api/content/getdata")] // or: [Route("~/api/content/getdata")] public string GetData() => "OK"; }/api/content/getdata/ または ~/ で始めると連結が無効化され、その文字列がそのままマッチングに使われる。
③ コントローラに [action] トークンを使う[Route("api/[controller]/[action]")] public class ContentController : ControllerBase { [HttpGet] // メソッド名 GetData が URL になる public string GetData() => "OK"; [HttpGet] public string Get() => "OK"; }/api/content/getdata、/api/content/getメソッド名を URL に露出したいときに便利。命名変更の影響に注意。

最小サンプル(.NET 9):すぐ動かして確かめる

プロジェクトを新規作成し、以下の 2 ファイルを配置して実行してください。

Program.cs

var builder = WebApplication.CreateBuilder(args);

// コントローラを有効化
builder.Services.AddControllers();

// 生成されるリンクの規約(任意・推奨)
builder.Services.Configure(o =>
{
o.LowercaseUrls = true;      // 生成 URL を小文字化
o.AppendTrailingSlash = false; // 末尾スラッシュは付けない
});

var app = builder.Build();

app.MapControllers();

// デバッグ用に登録された全エンドポイントをダンプする簡易ページ(任意)
app.MapGet("/_debug/routes", (IEnumerable dataSources) =>
{
var endpoints = dataSources.SelectMany(ds => ds.Endpoints).OfType();
return Results.Text(string.Join(Environment.NewLine,
endpoints.Select(e => $"{e.DisplayName} ⇒ {e.RoutePattern}")));
});

app.Run(); 

Controllers/ContentController.cs(解決策①の例)

using Microsoft.AspNetCore.Mvc;

namespace Demo.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet]
public string Get() => "OK";


[HttpGet("GetData")]
public string GetData() => "OK";


} 

起動後、以下で確認します。

# どちらも 200 OK が返る
curl http://localhost:5000/api/content
curl http://localhost:5000/api/content/getdata

# 登録済みルートの一覧も見える(任意)
curl http://localhost:5000/_debug/routes

「なぜ 404?」をもう一歩深掘り:連結の具体的なルール

  • ベース+相対の合成:コントローラのテンプレート(例:api/[controller])と、アクションのテンプレート(例:GetData)がパスセパレータで連結され、最終的なルートが決まる。
  • 絶対パスの宣言:アクションテンプレートを / または ~/ で始めると合成をスキップし、文字列どおりのルートになる。
  • トークン展開:[controller] はクラス名から Controller を除いた部分(例:Content)を小文字化して展開される(リンク生成は小文字化を有効化していれば小文字で出ます)。[action] はメソッド名、[area] はエリア名。
  • 大文字小文字:既定ではマッチングは ASCII で大文字小文字を区別しません。とはいえ、URL の見た目・SEO・可読性のために小文字で統一し、リンク生成も小文字化しておくのがベストです。
  • 末尾スラッシュ:テンプレートに末尾スラッシュを付けないのが無難。エンドポイントの定義と呼び出し側を一致させる運用で 404 の取り逃しを防ぎます。

現場で役立つデバッグ・確認テクニック

1) まずは「実際に登録されたエンドポイント」を見る

前述の /_debug/routes のように EndpointDataSource をダンプすると、アプリが認識している最終的なルートパターンを一覧できます。期待と結果がズレていないか、一目で分かります。

2) ルーティングの衝突チェック

属性ルートは順序よりもパターンが重要です。同じパスに複数のアクションがマッチすると曖昧さが発生します。HTTP メソッド([HttpGet]/[HttpPost] など)やルート制約(例:{id:int})で意図を明確化しましょう。

[Route("api/[controller]")]
public class ContentController : ControllerBase
{
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id) => Ok(id);

    [HttpGet("{slug}")]
    public IActionResult GetBySlug(string slug) => Ok(slug);
}

このように制約を使うと、/api/content/123 は前者、/api/content/abc は後者に振り分けられます。

3) 統合テストで「404 にならないこと」を固定化

using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public class RoutingTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    public RoutingTests(WebApplicationFactory<Program> factory)
        => _client = factory.CreateClient();

    [Theory]
    [InlineData("/api/content")]
    [InlineData("/api/content/getdata")]
    public async Task Endpoints_Should_Be_Accessible(string path)
    {
        var res = await _client.GetAsync(path);
        Assert.Equal(HttpStatusCode.OK, res.StatusCode);
    }
}

CI で回しておけばルート変更による事故を早期検知できます。

設計ベストプラクティス:運用で 404 を減らすために

  1. ベースはコントローラ、アクションは相対に徹する(解決策①)。URL 構造の一貫性が高まり、リファクタも簡単。
  2. 絶対パスが必要な場合のみ、明示的に / または ~/ で始める(解決策②)。
  3. 小文字 & 末尾スラッシュなしを規約にする(リンク生成を小文字化)。
  4. 冗長なセグメントの重複を避ける(api を二重にしない等)。
  5. バージョニングを導入する場合は、ベース側にまとめると整理しやすい: [Route("api/v{version:apiVersion}/[controller]")] [ApiVersion("1.0")] public class ContentController : ControllerBase { ... }
  6. ルート制約・名前付きルートを活用し、曖昧さを排除: [HttpGet("getdata", Name = "ContentGetData")]

よくある落とし穴(実例付き)

同じセグメントを二度書いてしまう

NG:

[Route("api/[controller]")]
public class ContentController : ControllerBase
{
    [HttpGet]
    [Route("api/content/getdata")] // ← ここが重複
    public string GetData() => "OK";
}

OK:

[Route("api/[controller]")]
public class ContentController : ControllerBase
{
    [HttpGet("getdata")]
    public string GetData() => "OK";
}

末尾スラッシュの取り扱い

テンプレートとリクエストの表記を合わせるのが確実です。リンク生成は AppendTrailingSlash = false にしておくと統一しやすく、ブックマークやドキュメントでも揺れが減ります。

Minimal API との共存

同一アプリで Minimal API(app.MapGet(...))とコントローラを併用する場合、パスの衝突に注意してください。app.MapControllers() を呼ぶ順序は大抵問題になりませんが、同じパターンを定義すると競合します。API 設計段階で領域を明確に分けるのが安全です。

環境差(IIS / Kestrel / リバースプロキシ)

リバースプロキシのプレフィックス(例:/api を前段で付与)との二重化で 404 を踏むケースもあります。アプリ側では PathBase を意識し、前段の書き換えとアプリ定義が二重にならないようにしましょう。

運用の型:コントローラ単位で統一する

チームでの実装ルールが混在すると、URL 設計の一貫性が崩れて保守コストが跳ね上がります。以下の「型」をチーム規約として導入するのがお勧めです。

型 A:最小・シンプル(推奨)

[ApiController]
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
    [HttpGet]             // GET /api/content
    public IActionResult Get() => Ok();


[HttpGet("getdata")]  // GET /api/content/getdata
public IActionResult GetData() => Ok();


} 

型 B:アクション名を露出

[ApiController]
[Route("api/[controller]/[action]")]
public class ContentController : ControllerBase
{
    [HttpGet] // GET /api/content/get
    public IActionResult Get() => Ok();


[HttpGet] // GET /api/content/getdata
public IActionResult GetData() => Ok();


} 

型 C:どうしても絶対パスが必要なときだけ

[ApiController]
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
    [HttpGet]
    [Route("/api/content/getdata")] // or "~/api/content/getdata"
    public IActionResult GetData() => Ok();
}

チェックリスト:実装前・レビュー時に見るポイント

  • コントローラに [Route("api/[controller]")] などのベースがあるか。
  • アクションのテンプレートは相対になっているか(GetData や "getdata")。
  • 絶対パスを使うなら / または ~/ で始めているか。
  • 同じセグメント(api/content など)をアクションで重複させていないか。
  • リンク生成の規約(小文字化・末尾スラッシュなし)が設定されているか。
  • 制約や HTTP メソッドで曖昧さを排除しているか。
  • Minimal API/前段プロキシとの衝突がないか。

トラブルシューティング:それでも 404 のとき

  1. エンドポイント一覧(/_debug/routes)で、狙いのパターンが登録されているか確認。無ければテンプレートの書き方を見直す。
  2. HTTP メソッド(GET/POST/PUT/DELETE)の取り違いがないか(curl -X GET で叩いているのに [HttpPost] になっていないか)。
  3. ルート制約に引っかかっていないか(例:{id:int} に文字列でアクセスしていないか)。
  4. 前段(IIS/Nginx/Apache/ALB 等)のリライト・ベースパス付与と二重化していないか。
  5. 環境変数・ベースパス(ASPNETCORE_URLS や UsePathBase)で予期せぬパスが混ざっていないか。

まとめ:404 の正体は「重ね書き」

今回の 404 の正体は、コントローラのベース api/[controller] に、アクション側でさらに api/content/getdata を重ね書きし、結果として /api/content/api/content/getdata ができてしまったことでした。解決策はシンプルです。

  • 基本は相対パスだけを書く([HttpGet("getdata")])。
  • どうしても必要なときのみ絶対パス([Route("/api/content/getdata")] または [Route("~/api/content/getdata")])。
  • URL の小文字統一・末尾スラッシュなしでブレを無くす。

この 3 点をチームの実装規約に落とし込めば、ASP.NET Core での 404 トラブルは劇的に減ります。ルートは設計です。小さな規約の積み重ねが、のちのちの保守性と品質に直結します。

付録:よく使うコードスニペット集

1) ルートの小文字化と末尾スラッシュ無効化

builder.Services.Configure<RouteOptions>(o =>
{
    o.LowercaseUrls = true;
    o.AppendTrailingSlash = false;
});

2) ルート制約の例

[HttpGet("{year:int:min(2000):max(2100)}/{month:int:range(1,12)}")]
public IActionResult GetMonthly(int year, int month) => Ok(new { year, month });

3) 名前付きルートと URL 生成

[HttpGet("getdata", Name = "ContentGetData")]
public IActionResult GetData() => Ok();

// どこか別の場所で URL を生成
public string BuildUrl([FromServices] LinkGenerator linker)
=> linker.GetPathByName(HttpContext, "ContentGetData", values: null) ?? "";

4) バージョニング(ルートにまとめる)

[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ContentController : ControllerBase
{
    [HttpGet("getdata")]
    public IActionResult GetDataV() => Ok();
}

5) 既存コードの「重複セグメント」を機械的に直すコツ

// Before(NG)
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
    [HttpGet]
    [Route("api/content/getdata")]
    public IActionResult GetData() => Ok();
}

// After(OK)
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet("getdata")]
public IActionResult GetData() => Ok();
}

ケーススタディ:既存プロジェクトでの移行手順

  1. 現状棚卸し:/_debug/routes で全エンドポイントを出力し、/api/api/... のような重複や、末尾スラッシュの揺れを洗い出す。
  2. 規約決定:「ベース=コントローラ、アクションは相対、絶対パス禁止(例外は要レビュー)」 を明文化。
  3. 一括修正:正規表現置換(例:\[Route\("api\/[a-zA-Z].*"\)\] をアクションから削除)で重複を除去。
  4. リンク生成の統一:LowercaseUrls と AppendTrailingSlash を設定。
  5. 自動テスト:主要エンドポイントの 200 応答テストを追加し、回帰を防止。

最後に:ミスを防ぐための「ルール一行」

「アクションの [Route] には 相対 だけを書く」──この一行を守るだけで、今回のような 404 はほぼ消えます。絶対パスが必要な特殊ケースでは / か ~/ のみを接頭に付ける。これが ASP.NET Core ルーティングを安定運用する最短ルートです。


Q&A:手早く解決したい人向けまとめ

やりたいこと書き方アクセス URL備考
ベースに api/[controller] を使い、GetData を公開[HttpGet("getdata")]/api/content/getdata最小・推奨
ベース無視で /api/content/getdata を直書き[Route("/api/content/getdata")] または [Route("~/api/content/getdata")]/api/content/getdata絶対パス。乱用注意
メソッド名を URL に反映したい[Route("api/[controller]/[action]")]/api/content/getdata, /api/content/get命名の破壊的変更に注意

この記事を書いた人

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

コメント

コメントする

目次