在開發 ASP․NET Core Web API 的 [HttpPost]
動作方法用來建立資料時,我們可以使用 CreatedAtAction
或 CreatedAtRoute
來回應訊息,但是可能會遇到 No route matches the supplied values
的問題,這篇文章我來說說問題發生的原因與解決方案。
建立範例 Web API 專案
以下我們用 .NET 7 為範例:
-
建立 api1
專案
dotnet new webapi -n AspNetCoreWebApiEFDemo && cd AspNetCoreWebApiEFDemo
-
加入 .gitignore
檔案
dotnet new gitignore
-
將專案加入 Git 版控
git init && git add . && git commit -m "Initial commit"
-
在資料庫中建立 ContosoUniversity 範例資料庫
建議使用 SQL Server Express LocalDB 即可:(localdb)\MSSQLLocalDB
-
安裝 dotnet-ef
全域工具 (.NET CLI Global Tool)
dotnet tool update -g dotnet-ef
-
安裝 Entity Framework Core 相關套件
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
建立新版本
git add . && git commit -m "Add EFCore NuGet packages"
-
透過 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
產生程式碼之後,必須先建置專案,雖然建置會成功,但是卻會看到警告訊息如下:
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
原始碼中,建議你手動將這段程式碼移除。
上圖修改完後的程式碼如下:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {}
建立新版本
git add . && git commit -m "Create EFCore models and dbcontext classes using dotnet-ef"
-
調整 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")));
-
調整 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"
-
建置專案,確認可以正常編譯!
dotnet build
-
建立方案檔
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
-
使用 Visual Studio 2022 開啟 AspNetCoreWebApiEFDemo.sln
方案檔
-
透過 Visual Studio 2022 工具產生 CoursesController
API 控制器
-
我們先調整一下 Models\Course.cs
的 Department
導覽屬性
原本的程式碼是:
public virtual Department Department { get; set; } = null!;
我們將其改為 nullable 的版本,以免 HTTP POST 無法建立資料:
public virtual Department? Department { get; set; }
-
透過 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":[]}
-
由於 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),我打算做出以下調整:
- 將所有非同步的方法都改成
Async
結尾
- 替每個 Action 加上路由名稱(Route Name)
- 路由名稱一律使用
nameof
語法取得字串值
現在,我的 GetCourse(int id)
與 PostCourse(Course course)
這兩個 Action 變更如下:
-
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;
}
-
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 種,而我覺得最漂亮的解法是最後一種:
- 繼續用
CreatedAtAction
但設定正確的名稱
- 繼續用
CreatedAtAction
但替 Action 加上 [ActionName]
屬性
- 改用
CreatedAtRoute
設定路由名稱
以下是解決方案的原始碼:
-
繼續用 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", "")
-
繼續用 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);
}
總結
這個問題主要卡在你對 CreatedAtAction
與 CreatedAtRoute
的理解,Action 與 Route 由於定義不一樣,如果沒有 MVC 的基礎,要瞭解 Action Name 的特性其實不太直觀,所以我還是認為上述的第 3 種解決方案才是比較好的解決方案,寫 ASP.NET Core Web API 時不要再依賴 Action Name 來寫 Code 了!
相關連結