The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

ASP.NET Core Web API 遭遇 No route matches the supplied values 的問題

在開發 ASP․NET Core Web API 的 [HttpPost] 動作方法用來建立資料時,我們可以使用 CreatedAtActionCreatedAtRoute 來回應訊息,但是可能會遇到 No route matches the supplied values 的問題,這篇文章我來說說問題發生的原因與解決方案。

API

建立範例 Web API 專案

以下我們用 .NET 7 為範例:

  1. 建立 api1 專案

    dotnet new webapi -n AspNetCoreWebApiEFDemo && cd AspNetCoreWebApiEFDemo
    
  2. 加入 .gitignore 檔案

    dotnet new gitignore
    
  3. 將專案加入 Git 版控

    git init && git add . && git commit -m "Initial commit"
    
  4. 在資料庫中建立 ContosoUniversity 範例資料庫

    建議使用 SQL Server Express LocalDB 即可:(localdb)\MSSQLLocalDB

  5. 安裝 dotnet-ef 全域工具 (.NET CLI Global Tool)

    dotnet tool update -g dotnet-ef
    
  6. 安裝 Entity Framework Core 相關套件

    dotnet add package Microsoft.EntityFrameworkCore.SqlServer
    dotnet add package Microsoft.EntityFrameworkCore.Tools
    

    建立新版本

    git add . && git commit -m "Add EFCore NuGet packages"
    
  7. 透過 dotnet-ef 快速建立 EFCore 模型類別與資料內容類別

    dotnet ef dbcontext scaffold "Server=(localdb)\MSSQLLocalDB;Database=ContosoUniversity;Trusted_Connection=True;MultipleActiveResultSets=true" Microsoft.EntityFrameworkCore.SqlServer -o Models
    dotnet build
    

    在透過 dotnet ef 產生程式碼之後,必須先建置專案,雖然建置會成功,但是卻會看到警告訊息如下:

    warning CS1030: #warning: 'To protect potentially sensitive information in your connection string, you should move it out of source code. See http://go.microsoft.com/fwlink/?LinkId=723263 for guidance on storing connection strings.'

    Models\ContosoUniversityContext.cs(32,10): warning CS1030: #warning: 'To protect potentially sensitive information in your connection string, you should move it out of source code. See http://go.microsoft.com/fwlink/?LinkId=723263 for guidance on storing connection strings.' [G:\Projects\api1\api1.csproj]
    

    這個警告訊息主要是跟你說,在透過 dotnet-ef 工具產生程式碼的時候,會順便將「連接字串」一定產生在 ContosoUniversityContext.cs 原始碼中,建議你手動將這段程式碼移除。

    To protect potentially sensitive information in your connection string, you should move it out of source code.

    上圖修改完後的程式碼如下:

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {}
    

    建立新版本

    git add . && git commit -m "Create EFCore models and dbcontext classes using dotnet-ef"
    
  8. 調整 ASP․NET Core 的 Program.cs 並對 ContosoUniversityContext 設定 DI

    using AspNetCoreWebApiEFDemo.Models;
    using Microsoft.EntityFrameworkCore;
    
    var builder = WebApplication.CreateBuilder(args);
    
    // 加入這段程式碼
    builder.Services.AddDbContext<ContosoUniversityContext>(options =>
        options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
    
  9. 調整 ASP․NET Core 的 appsettings.Development.json 加入 DefaultConnection 連接字串

    {
      "ConnectionStrings": {
          "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Initial Catalog=ContosoUniversity;Integrated Security=True"
      },
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      }
    }
    

    建立新版本

    git add . && git commit -m "Add DI for ContosoUniversityContext and ConnectionStrings"
    
  10. 建置專案,確認可以正常編譯!

    dotnet build
    
  11. 建立方案檔

    dotnet new sln
    dotnet sln add AspNetCoreWebApiEFDemo.csproj
    

    建立新版本

    git add . && git commit -m "Add Solution File for Visual Studio 2022"
    

測試 POST 建立資料的 API

上述建立範例 Web API 專案我上傳到 AspNetCoreWebApiEFDemo 專案中,你也可以跳過這些步驟,直接 git clone 回來使用。

git clone https://github.com/doggy8088/AspNetCoreWebApiEFDemo.git
cd AspNetCoreWebApiEFDemo
  1. 使用 Visual Studio 2022 開啟 AspNetCoreWebApiEFDemo.sln 方案檔

  2. 透過 Visual Studio 2022 工具產生 CoursesController API 控制器

    image

    image

    image

  3. 我們先調整一下 Models\Course.csDepartment 導覽屬性

    原本的程式碼是:

    public virtual Department Department { get; set; } = null!;
    

    我們將其改為 nullable 的版本,以免 HTTP POST 無法建立資料:

    public virtual Department? Department { get; set; }
    
  4. 透過 curl 呼叫 API 用 POST 建立一筆新資料到 Courses 資料表中

    curl -v -d '{ "title": "ASP.NET Core 6 開發實戰", "credits": 1, "departmentId": 5 }' -H 'Content-Type: application/json' https://localhost:7054/api/Courses
    

    其回應結果如下,代表資料建立成功:

    *   Trying 127.0.0.1:7054...
    * Connected to localhost (127.0.0.1) port 7054 (#0)
    * schannel: disabled automatic use of client certificate
    * ALPN: offers http/1.1
    * ALPN: server accepted http/1.1
    * using HTTP/1.1
    > POST /api/Courses HTTP/1.1
    > Host: localhost:7054
    > User-Agent: curl/8.0.1
    > Accept: */*
    > Content-Type: application/json
    > Content-Length: 75
    >
    < HTTP/1.1 201 Created
    < Content-Type: application/json; charset=utf-8
    < Date: Wed, 19 Apr 2023 00:49:18 GMT
    < Server: Kestrel
    < Location: https://localhost:7054/api/Courses/18
    < Transfer-Encoding: chunked
    <
    {"courseId":18,"title":"ASP.NET Core 6 開發實戰","credits":1,"departmentId":5,"department":null,"enrollments":[],"instructors":[]}* Connection #0 to host localhost left intact
    

    接著呼叫 GET 方法,取得 Location 指向的那筆資料的端點(Endpoint)

    curl https://localhost:7054/api/Courses/18
    

    回應結果如下:

    {"courseId":18,"title":"ASP.NET Core 6 開發實戰","credits":1,"departmentId":5,"department":null,"enrollments":[],"instructors":[]}
    
  5. 由於 API 可以成功呼叫,我們就來看一下目前這段可以運作的 Code 長怎樣

    這裡的亮點在於 HTTP 201 Created 所需的 ActionResult 使用了 CreatedAtAction 來當成 ActionResult 的回傳值:

    [HttpPost]
    public async Task<ActionResult<Course>> PostCourse(Course course)
    {
        if (_context.Courses == null)
        {
            return Problem("Entity set 'ContosoUniversityContext.Courses'  is null.");
        }
        _context.Courses.Add(course);
        await _context.SaveChangesAsync();
    
        return CreatedAtAction("GetCourse", new { id = course.CourseId }, course);
    }
    

    CreatedAtAction 呼叫的第一個參數 GetCourse 是同一個 Controller 中的 Action Name,而 new { id = course.CourseId } 則是路由值,而 course 則是要回傳在 Response Body 的 Payload。

    事實上,這個 Controller 有個 Action 就叫 GetCourse,請注意,以下「方法名稱」預設就等於 Action Name,這是從 MVC 直接繼承下來的傳統:

    // GET: api/Courses/5
    [HttpGet("{id}")]
    public async Task<ActionResult<Course>> GetCourse(int id)
    {
        if (_context.Courses == null)
        {
            return NotFound();
        }
        var course = await _context.Courses.FindAsync(id);
    
        if (course == null)
        {
            return NotFound();
        }
    
        return course;
    }
    

重現有問題的實作

為了讓我的 API 可以更佳健壯(robust),我打算做出以下調整:

  1. 將所有非同步的方法都改成 Async 結尾
  2. 替每個 Action 加上路由名稱(Route Name)
  3. 路由名稱一律使用 nameof 語法取得字串值

現在,我的 GetCourse(int id)PostCourse(Course course) 這兩個 Action 變更如下:

  1. GetCourse 改為 GetCourseByIdAsync

    我有替這個 Action 加上路由名稱(Route Name)為 GetCourseByIdAsync,但是你絕對想不到 ActionName 變成了什麼,因為你完全會看不出來的!我先說答案:是 GetCourseById,也就是非同步方法中常見的 Async 命名結尾,到了 Action Name 會自動移除,這是 MVC 需要的特性。

    [HttpGet("{id}", Name = nameof(GetCourseByIdAsync))]
    public async Task<ActionResult<Course>> GetCourseByIdAsync(int id)
    {
        if (_context.Courses == null)
        {
            return NotFound();
        }
        var course = await _context.Courses.FindAsync(id);
    
        if (course == null)
        {
            return NotFound();
        }
    
        return course;
    }
    
  2. PostCourse 改為 GetCourseByIdAsync

    這時問題出現了,問題就出在 CreatedAtAction 這行的第一個參數,因為 ActionName 用 GetCourseByIdAsync 是不正確的,要用 GetCourseById 才對!

    [HttpPost(Name = nameof(PostCourseAsync))]
    public async Task<ActionResult<Course>> PostCourseAsync(Course course)
    {
        if (_context.Courses == null)
        {
            return Problem("Entity set 'ContosoUniversityContext.Courses'  is null.");
        }
        _context.Courses.Add(course);
        await _context.SaveChangesAsync();
    
        return CreatedAtAction(nameof(GetCourseByIdAsync), new { id = course.CourseId }, course);
    }
    

    此時你可以用 POST 再建立一筆資料試試:

    curl -v -d '{ "title": "ASP.NET Core 6 開發實戰", "credits": 1, "departmentId": 5 }' -H 'Content-Type: application/json' https://localhost:7054/api/Courses
    

    你將發現以下 No route matches the supplied values. 錯誤訊息,掛掉的地方則是 Microsoft.AspNetCore.Mvc.CreatedAtActionResult.OnFormatting 方法:

    System.InvalidOperationException: No route matches the supplied values.
      at Microsoft.AspNetCore.Mvc.CreatedAtActionResult.OnFormatting(ActionContext context)
      at Microsoft.AspNetCore.Mvc.Infrastructure.ObjectResultExecutor.ExecuteAsyncCore(ActionContext context, ObjectResult result, Type objectType, Object value)
      at Microsoft.AspNetCore.Mvc.Infrastructure.ObjectResultExecutor.ExecuteAsync(ActionContext context, ObjectResult result)
      at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.ResultNext[TFilter,TFilterAsync](State& next, Scope& scope, Object& state, Boolean& isCompleted)
      at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeNextResultFilterAsync[TFilter,TFilterAsync]()
    --- End of stack trace from previous location ---
      at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResultExecutedContextSealed context)
      at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.ResultNext[TFilter,TFilterAsync](State& next, Scope& scope, Object& state, Boolean& isCompleted)
      at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeResultFilters()
    --- End of stack trace from previous location ---
      at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
      at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
      at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
      at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)
      at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
      at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)
      at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
      at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
      at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
    

解決方案

知道原因就好了,原來是 ActionName 不是我們想像的那個樣子,但說實在的,我們寫 ASP.NET Core Web API 的時候,根本用不太到動作名稱(Action Name),改用路由名稱(Route Name)才是比較合理的寫法,所以解決方案有 3 種,而我覺得最漂亮的解法是最後一種:

  1. 繼續用 CreatedAtAction 但設定正確的名稱
  2. 繼續用 CreatedAtAction 但替 Action 加上 [ActionName] 屬性
  3. 改用 CreatedAtRoute 設定路由名稱

以下是解決方案的原始碼:

  1. 繼續用 CreatedAtAction 但設定正確的名稱

    [HttpPost(Name = nameof(PostCourseAsync))]
    public async Task<ActionResult<Course>> PostCourseAsync(Course course)
    {
        if (_context.Courses == null)
        {
            return Problem("Entity set 'ContosoUniversityContext.Courses'  is null.");
        }
        _context.Courses.Add(course);
        await _context.SaveChangesAsync();
    
        return CreatedAtAction(nameof(GetCourseByIdAsync).Replace("Async", ""), new { id = course.CourseId }, course);
    }
    

    亮點在這裡:

    nameof(GetCourseByIdAsync).Replace("Async", "")
    
  2. 繼續用 CreatedAtAction 但替 Action 加上 [ActionName] 屬性

    我個人不太喜歡這種解決方案,因為 ASP.NET Web API 根本用不到 Action Name

    [HttpGet("{id}", Name = nameof(GetCourseByIdAsync))]
    [ActionName(nameof(GetCourseByIdAsync))]
    public async Task<ActionResult<Course>> GetCourseByIdAsync(int id)
    {
        if (_context.Courses == null)
        {
            return NotFound();
        }
        var course = await _context.Courses.FindAsync(id);
    
        if (course == null)
        {
            return NotFound();
        }
    
        return course;
    }
    
  • 改用 CreatedAtRoute 設定路由名稱

    這是我覺得最理想的解決方案,指定路由名稱才是王道!👍

    [HttpPost(Name = nameof(PostCourseAsync))]
    public async Task<ActionResult<Course>> PostCourseAsync(Course course)
    {
        if (_context.Courses == null)
        {
            return Problem("Entity set 'ContosoUniversityContext.Courses'  is null.");
        }
        _context.Courses.Add(course);
        await _context.SaveChangesAsync();
    
        return CreatedAtRoute(nameof(GetCourseByIdAsync), new { id = course.CourseId }, course);
    }
    

總結

這個問題主要卡在你對 CreatedAtActionCreatedAtRoute 的理解,Action 與 Route 由於定義不一樣,如果沒有 MVC 的基礎,要瞭解 Action Name 的特性其實不太直觀,所以我還是認為上述的第 3 種解決方案才是比較好的解決方案,寫 ASP.NET Core Web API 時不要再依賴 Action Name 來寫 Code 了!

相關連結

留言評論