在上篇文章 ASP.NET Core 最小 API:極簡開發,高效構建(上) 中我們添加了 API 代碼并且測試,本篇繼續補充相關內容。
一、使用 MapGroup API
示例應用代碼每次設置終結點時都會重復 todoitems
URL 前綴。 API 通常具有帶常見 URL 前綴的終結點組,并且 MapGroup
方法可用于幫助組織此類組。 它減少了重復代碼,并允許通過對 RequireAuthorization
和 WithMetadata
等方法的單一調用來自定義整個終結點組。
將 Program.cs 的內容替換為以下代碼:
using Microsoft.EntityFrameworkCore;
using NSwag.AspNetCore;
using TodoApi;var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApiDocument(config =>
{config.DocumentName = "TodoAPI";config.Title = "TodoAPI v1";config.Version = "v1";
});var app = builder.Build();if (app.Environment.IsDevelopment())
{app.UseOpenApi();app.UseSwaggerUi(config =>{config.DocumentTitle = "TodoAPI";config.Path = "/swagger";config.DocumentPath = "/swagger/{documentName}/swagger.json";config.DocExpansion = "list";});
}var todoItems = app.MapGroup("/todoitems");todoItems.MapGet("/", async (TodoDb db) =>await db.Todos.ToListAsync());todoItems.MapGet("/complete", async (TodoDb db) =>await db.Todos.Where(t => t.IsComplete).ToListAsync());todoItems.MapGet("/{id}", async (int id, TodoDb db) =>await db.Todos.FindAsync(id)is Todo todo? Results.Ok(todo): Results.NotFound());todoItems.MapPost("/", async (Todo todo, TodoDb db) =>
{db.Todos.Add(todo);await db.SaveChangesAsync();return Results.Created($"/todoitems/{todo.Id}", todo);
});todoItems.MapPut("/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{var todo = await db.Todos.FindAsync(id);if (todo is null) return Results.NotFound();todo.Name = inputTodo.Name;todo.IsComplete = inputTodo.IsComplete;await db.SaveChangesAsync();return Results.NoContent();
});todoItems.MapDelete("/{id}", async (int id, TodoDb db) =>
{if (await db.Todos.FindAsync(id) is Todo todo){db.Todos.Remove(todo);await db.SaveChangesAsync();return Results.NoContent();}return Results.NotFound();
});app.Run();
前面的代碼執行以下更改:
- 添加
var todoItems = app.MapGroup("/todoitems");
以使用 URL 前綴/todoitems
設置組。 - 將所有
app.Map<HttpVerb>
方法更改為todoItems.Map<HttpVerb>
。 - 從
/todoitems
方法調用中移除 URL 前綴Map<HttpVerb>
。
二、使用 TypedResults API
返回 TypedResults(而不是 Results)有幾個優點,包括可測試性和自動返回 OpenAPI 的響應類型元數據來描述終結點。有關詳細信息,請參閱 TypedResults 與 Results。
使用以下代碼更新 Program.cs
:
using Microsoft.EntityFrameworkCore;
using NSwag.AspNetCore;
using TodoApi;var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApiDocument(config =>
{config.DocumentName = "TodoAPI";config.Title = "TodoAPI v1";config.Version = "v1";
});var app = builder.Build();if (app.Environment.IsDevelopment())
{app.UseOpenApi();app.UseSwaggerUi(config =>{config.DocumentTitle = "TodoAPI";config.Path = "/swagger";config.DocumentPath = "/swagger/{documentName}/swagger.json";config.DocExpansion = "list";});
}var todoItems = app.MapGroup("/todoitems");todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);app.Run();static async Task<IResult> GetAllTodos(TodoDb db)
{return TypedResults.Ok(await db.Todos.ToArrayAsync());
}static async Task<IResult> GetCompleteTodos(TodoDb db)
{return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).ToListAsync());
}static async Task<IResult> GetTodo(int id, TodoDb db)
{return await db.Todos.FindAsync(id)is Todo todo? TypedResults.Ok(todo): TypedResults.NotFound();
}static async Task<IResult> CreateTodo(Todo todo, TodoDb db)
{db.Todos.Add(todo);await db.SaveChangesAsync();return TypedResults.Created($"/todoitems/{todo.Id}", todo);
}static async Task<IResult> UpdateTodo(int id, Todo inputTodo, TodoDb db)
{var todo = await db.Todos.FindAsync(id);if (todo is null) return TypedResults.NotFound();todo.Name = inputTodo.Name;todo.IsComplete = inputTodo.IsComplete;await db.SaveChangesAsync();return TypedResults.NoContent();
}static async Task<IResult> DeleteTodo(int id, TodoDb db)
{if (await db.Todos.FindAsync(id) is Todo todo){db.Todos.Remove(todo);await db.SaveChangesAsync();return TypedResults.NoContent();}return TypedResults.NotFound();
}
Map<HttpVerb>
代碼現在調用方法,而不是 lambda:
var todoItems = app.MapGroup("/todoitems");todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);
這些方法返回實現 IResult 并由 TypedResults 定義的對象:
static async Task<IResult> GetAllTodos(TodoDb db)
{return TypedResults.Ok(await db.Todos.ToArrayAsync());
}static async Task<IResult> GetCompleteTodos(TodoDb db)
{return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).ToListAsync());
}static async Task<IResult> GetTodo(int id, TodoDb db)
{return await db.Todos.FindAsync(id)is Todo todo? TypedResults.Ok(todo): TypedResults.NotFound();
}static async Task<IResult> CreateTodo(Todo todo, TodoDb db)
{db.Todos.Add(todo);await db.SaveChangesAsync();return TypedResults.Created($"/todoitems/{todo.Id}", todo);
}static async Task<IResult> UpdateTodo(int id, Todo inputTodo, TodoDb db)
{var todo = await db.Todos.FindAsync(id);if (todo is null) return TypedResults.NotFound();todo.Name = inputTodo.Name;todo.IsComplete = inputTodo.IsComplete;await db.SaveChangesAsync();return TypedResults.NoContent();
}static async Task<IResult> DeleteTodo(int id, TodoDb db)
{if (await db.Todos.FindAsync(id) is Todo todo){db.Todos.Remove(todo);await db.SaveChangesAsync();return TypedResults.NoContent();}return TypedResults.NotFound();
}
三、防止過度發布
目前,示例應用公開了整個 Todo 對象。 在生產應用中,通常使用模型的一個子集來限制可以輸入和返回的數據。 這背后有多種原因,但安全性是主要原因。 模型的子集通常稱為數據傳輸對象 (DTO)、輸入模型或視圖模型。 本文使用的是 DTO。
DTO 可以用于:
- 防止過度發布。
- 隱藏客戶端不應查看的屬性。
- 省略某些屬性以減少有效負載大小。
- 平展包含嵌套對象的對象圖。 對客戶端而言,平展的對象圖可能更方便。
更新 Todo 類,使其包含機密字段:
public class Todo
{public int Id { get; set; }public string? Name { get; set; }public bool IsComplete { get; set; }public string? Secret { get; set; }
}
此應用需要隱藏機密字段,但管理應用可以選擇公開它。使用以下代碼創建名為 TodoItemDTO.cs
的文件:
public class TodoItemDTO
{public int Id { get; set; }public string? Name { get; set; }public bool IsComplete { get; set; }public TodoItemDTO() { }public TodoItemDTO(Todo todoItem) =>(Id, Name, IsComplete) = (todoItem.Id, todoItem.Name, todoItem.IsComplete);
}
將 Program.cs
文件的內容替換為以下代碼以使用此 DTO 模型:
using Microsoft.EntityFrameworkCore;
using NSwag.AspNetCore;
using TodoApi;var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApiDocument(config =>
{config.DocumentName = "TodoAPI";config.Title = "TodoAPI v1";config.Version = "v1";
});var app = builder.Build();if (app.Environment.IsDevelopment())
{app.UseOpenApi();app.UseSwaggerUi(config =>{config.DocumentTitle = "TodoAPI";config.Path = "/swagger";config.DocumentPath = "/swagger/{documentName}/swagger.json";config.DocExpansion = "list";});
}app.MapGet("/todoitems", async (TodoDb db) =>await db.Todos.Select(x => new TodoItemDTO(x)).ToListAsync());app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>await db.Todos.FindAsync(id)is Todo todo? Results.Ok(new TodoItemDTO(todo)): Results.NotFound());app.MapPost("/todoitems", async (TodoItemDTO todoItemDTO, TodoDb db) =>
{var todoItem = new Todo{IsComplete = todoItemDTO.IsComplete,Name = todoItemDTO.Name};db.Todos.Add(todoItem);await db.SaveChangesAsync();return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});app.MapPut("/todoitems/{id}", async (int id, TodoItemDTO todoItemDTO, TodoDb db) =>
{var todo = await db.Todos.FindAsync(id);if (todo is null) return Results.NotFound();todo.Name = todoItemDTO.Name;todo.IsComplete = todoItemDTO.IsComplete;await db.SaveChangesAsync();return Results.NoContent();
});app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{if (await db.Todos.FindAsync(id) is Todo todo){db.Todos.Remove(todo);await db.SaveChangesAsync();return Results.NoContent();}return Results.NotFound();
});app.Run();
運行效果,
四、配置 JSON 序列化選項
1、全局配置 JSON 序列化選項
可通過調用 ConfigureHttpJsonOptions
來全局配置應用的選項。 以下示例包含公共字段,并設置 JSON 輸出的格式。
var builder = WebApplication.CreateBuilder(args);builder.Services.ConfigureHttpJsonOptions(options => {options.SerializerOptions.WriteIndented = true;options.SerializerOptions.IncludeFields = true;
});var app = builder.Build();app.MapPost("/", (Todo todo) => {if (todo is not null) {todo.Name = todo.NameField;}return todo;
});app.Run();class Todo {public string? Name { get; set; }public string? NameField;public bool IsComplete { get; set; }
}
運行效果:
當請求報文為
{"name": "test","isComplete": true
}
則返回
{"name": null,"isComplete": true,"nameField": null
}
當請求報文為
{"nameField": "Walk dog","isComplete": false
}
則返回
{"name": "Walk dog","isComplete": false,"nameField": "Walk dog"
}
2、為終結點配置 JSON 序列化選項
若要為終結點配置序列化選項,請調用 Results.Json
并向其傳遞 JsonSerializerOptions
對象,如以下示例所示:
using System.Text.Json;var app = WebApplication.Create();var options = new JsonSerializerOptions(JsonSerializerDefaults.Web){ WriteIndented = true };app.MapGet("/", () => Results.Json(new Todo { Name = "Walk dog", IsComplete = false }, options));app.Run();class Todo
{public string? Name { get; set; }public bool IsComplete { get; set; }
}
運行效果,
或者,使用接受 JsonSerializerOptions
對象的 WriteAsJsonAsync
的重載。 以下示例使用此重載設置輸出 JSON 的格式:
using System.Text.Json;var app = WebApplication.Create();var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) {WriteIndented = true };app.MapGet("/", (HttpContext context) =>context.Response.WriteAsJsonAsync<Todo>(new Todo { Name = "Walk dog", IsComplete = false }, options));app.Run();class Todo
{public string? Name { get; set; }public bool IsComplete { get; set; }
}
運行效果:
五、最小 API 中的身份驗證和授權
1、有關身份驗證和授權的關鍵概念
身份驗證是確定用戶標識的過程。 授權是確定用戶是否有權訪問資源的過程。 在 ASP.NET Core 中,身份驗證和授權方案都有類似的實現語義。 身份驗證由身份驗證服務 IAuthenticationService
負責,而它供身份驗證中間件使用。 授權由授權服務 IAuthorizationService
負責,而它供授權中間件使用。
身份驗證服務會使用已注冊的身份驗證處理程序來完成與身份驗證相關的操作。 例如,與身份驗證相關的操作是對用戶進行身份驗證或注銷用戶。 身份驗證方案是用于唯一地標識身份驗證處理程序及其配置選項的名稱。 身份驗證處理程序負責實現身份驗證策略,并在給定特定身份驗證策略(如 OAuth 或 OIDC)的情況下生成用戶的聲明。 配置選項也是策略獨有的,并為處理程序提供會影響身份驗證行為的配置,例如重定向 URI。
在授權層中,有兩種策略可用于確定用戶對資源的訪問權限:
- 基于角色的策略根據所分配的角色(例如 Administrator 或 User)確定用戶的訪問權限。
- 基于聲明的策略根據中央頒發機構頒發的聲明來確定用戶的訪問權限。
在 ASP.NET Core 中,這兩種策略都被捕獲到授權要求中。 授權服務利用授權處理程序來確定特定用戶是否滿足應用于資源的授權要求。
2、在最小應用中啟用身份驗證
若要啟用身份驗證,請調用 AddAuthentication
以對應用的服務提供商注冊所需的身份驗證服務。
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication();
var app = builder.Build();app.MapGet("/", () => "Hello World!");
app.Run();
通常情況下,會使用一個特定的身份驗證策略。 在以下示例中,應用被配置為支持基于 JWT 持有者的身份驗證。 此示例使用 Microsoft.AspNetCore.Authentication.JwtBearer
NuGet 包中提供的 API。
var builder = WebApplication.CreateBuilder(args);
// Requires Microsoft.AspNetCore.Authentication.JwtBearer
builder.Services.AddAuthentication().AddJwtBearer();
var app = builder.Build();app.MapGet("/", () => "Hello World!");
app.Run();
默認情況下,如果啟用了某些身份驗證和授權服務,WebApplication
會自動注冊身份驗證和授權中間件。 在以下示例中,無需調用 UseAuthentication
或 UseAuthorization
即可注冊中間件,因為在調用 WebApplication
或 AddAuthentication
后,AddAuthorization
會自動執行此操作。
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();app.MapGet("/", () => "Hello World!");
app.Run();
具體原理可以查閱最小 API 應用中的中間件。在某些情況(例如控制中間件順序)下,需要顯式注冊身份驗證和授權。 在以下示例中,身份驗證中間件是在 CORS 中間件運行后運行的。
var builder = WebApplication.CreateBuilder(args);builder.Services.AddCors();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();var app = builder.Build();app.UseCors();
app.UseAuthentication();
app.UseAuthorization();app.MapGet("/", () => "Hello World!");
app.Run();
3、配置身份驗證策略
身份驗證策略通常支持通過選項加載的各種配置。 對于以下身份驗證策略,最小應用支持從配置加載選項:
- 基于 JWT 持有者
- 基于 OpenID 連接
ASP.NET Core 框架期望在Authentication:Schemes:{SchemeName}
節的配置部分下找到這些選項。 在以下示例中,兩個不同的方案 Bearer
和 LocalAuthIssuer
是使用各自的選項定義的。 Authentication:DefaultScheme
選項可用于配置所使用的默認身份驗證策略。
{"Authentication": {"DefaultScheme": "LocalAuthIssuer","Schemes": {"Bearer": {"ValidAudiences": ["https://localhost:7259","http://localhost:5259"],"ValidIssuer": "dotnet-user-jwts"},"LocalAuthIssuer": {"ValidAudiences": ["https://localhost:7259","http://localhost:5259"],"ValidIssuer": "local-auth"}}}
}
在 Program.cs
中,注冊了兩個基于 JWT 持有者的身份驗證策略,其中:
- “Bearer”方案名稱。
- “LocalAuthIssuer”方案名稱。
“Bearer”是支持基于 JWT 持有者的應用中的一個典型默認方案,但可以通過設置 DefaultScheme 屬性來替代默認方案,如前面的示例所示。
方案名稱用于唯一地標識身份驗證策略,并在從配置解析身份驗證選項時用作查找鍵,如以下示例所示:
var builder = WebApplication.CreateBuilder(args);builder.Services.AddAuthentication().AddJwtBearer().AddJwtBearer("LocalAuthIssuer");var app = builder.Build();app.MapGet("/", () => "Hello World!");
app.Run();
4、在最小應用中配置授權策略
身份驗證用于根據 API 識別和驗證用戶的標識。 授權用于驗證和證實對 API 中資源的訪問,并由通過 AddAuthorization
擴展方法注冊的 IAuthorizationService
提供便利。 在以下方案中,添加了 /hello
資源,該資源要求用戶提供一個 admin
角色聲明以及 greetings_api
范圍聲明。
配置資源上的授權要求的過程分為兩個步驟,需要:
- 全局配置策略中的授權要求。
- 將各個策略應用于資源。
在以下代碼中,將調用 AddAuthorizationBuilder
,其作用是:
- 將與授權相關的服務添加到 DI 容器。
- 返回一個
AuthorizationBuilder
,它可用于直接注冊身份驗證策略。
該代碼創建了一個名為 admin_greetings
的新授權策略,該策略封裝了兩個授權要求:
- 一個通過
RequireRole
實現的基于角色的要求,面向具有admin
角色的用戶。 - 一個通過
RequireClaim
實現的基于聲明的要求,即用戶必須提供greetings_api
范圍聲明。
admin_greetings
策略作為 /hello
終結點所需的策略提供。
using Microsoft.Identity.Web;var builder = WebApplication.CreateBuilder(args);builder.Services.AddAuthorizationBuilder().AddPolicy("admin_greetings", policy =>policy.RequireRole("admin").RequireClaim("scope", "greetings_api"));var app = builder.Build();app.MapGet("/hello", () => "Hello world!").RequireAuthorization("admin_greetings");app.Run();
若無權限,則直接返回 401 Unauthorized
錯誤。
六、使用 dotnet user-jwts 進行開發測試
本文使用配置了基于 JWT 持有者的身份驗證的應用。 基于 JWT 令牌持有者的身份驗證要求客戶端在請求頭中呈現令牌,以驗證其身份和聲明。 通常,這些令牌由中央頒發機構頒發,例如標識服務器。
在本地計算機上進行開發時,dotnet user-jwts
工具可用于創建持有者令牌。
dotnet user-jwts create
注意
在項目上調用時,該工具會自動將與生成的令牌匹配的身份驗證選項添加到 appsettings.json
。
可以通過多種自定義方式配置令牌。 例如,若要為上述代碼中的授權策略所需的 admin
角色和 greetings_api
范圍創建令牌,請執行以下操作:
dotnet user-jwts create --scope "greetings_api" --role "admin"
然后,可以在所選的測試工具中將生成的令牌作為標頭的一部分發送。 舉個使用 curl 的例子:
curl -i -H "Authorization: Bearer {token}" http://localhost:{port}/hello
(base) sam@sam-PC:/data/home/sam/MyWorkSpace/DotNetCoreWorkSpace/TodoApi$ dotnet user-jwts create --scope "greetings_api" --role "admin"
New JWT saved with ID 'b0fafeb4'.
Name: sam
Roles: [admin]
Scopes: greetings_apiToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InNhbSIsInN1YiI6InNhbSIsImp0aSI6ImIwZmFmZWI0Iiwic2NvcGUiOiJncmVldGluZ3NfYXBpIiwicm9sZSI6ImFkbWluIiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MzQ0MiIsImh0dHBzOi8vbG9jYWxob3N0OjQ0MzgwIiwiaHR0cDovL2xvY2FsaG9zdDo1MDI2IiwiaHR0cHM6Ly9sb2NhbGhvc3Q6NzE1MyJdLCJuYmYiOjE3NDUwNTcwNDcsImV4cCI6MTc1MjkxOTQ0NywiaWF0IjoxNzQ1MDU3MDQ4LCJpc3MiOiJkb3RuZXQtdXNlci1qd3RzIn0.Rwgp9wO9PCLwJEiVgN-CGwlnLaAu0jhQXuzeo8Wh6Zg
curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InNhbSIsInN1YiI6InNhbSIsImp0aSI6ImIwZmFmZWI0Iiwic2NvcGUiOiJncmVldGluZ3NfYXBpIiwicm9sZSI6ImFkbWluIiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MzQ0MiIsImh0dHBzOi8vbG9jYWxob3N0OjQ0MzgwIiwiaHR0cDovL2xvY2FsaG9zdDo1MDI2IiwiaHR0cHM6Ly9sb2NhbGhvc3Q6NzE1MyJdLCJuYmYiOjE3NDUwNTcwNDcsImV4cCI6MTc1MjkxOTQ0NywiaWF0IjoxNzQ1MDU3MDQ4LCJpc3MiOiJkb3RuZXQtdXNlci1qd3RzIn0.Rwgp9wO9PCLwJEiVgN-CGwlnLaAu0jhQXuzeo8Wh6Zg" http://localhost:5026/hello
可以看到,攜帶 token 請求可以正常訪問 /hello
接口。完整代碼如下:
using Microsoft.Identity.Web;var builder = WebApplication.CreateBuilder(args);builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer").AddJwtBearer();builder.Services.AddAuthorizationBuilder().AddPolicy("admin_greetings", policy =>policy.RequireRole("admin").RequireClaim("scope", "greetings_api"));var app = builder.Build();app.UseAuthorization();app.MapGet("/hello", () => "Hello world!").RequireAuthorization("admin_greetings");app.Run();
參考文檔
-
教程:使用 ASP.NET Core 創建最小 API
-
最小 API 中的身份驗證和授權
-
使用 dotnet user-jwts 管理開發中的 JSON Web 令牌