在上篇文章 ASP.NET Core 最小 API:极简开发,高效构建(上) 中我们添加了 API 代码并且测试,本篇继续补充相关内容。
一、使用 MapGroup API
示例应用代码每次设置终结点时都会重复 todoitems
URL 前缀。 API 通常具有带常见 URL 前缀的终结点组,并且 MapGroup
方法可用于帮助组织此类组。 它减少了重复代码,并允许通过对 RequireAuthorization
和 WithMetadata
等方法的单一调用来自定义整个终结点组。
将 Program.cs 的内容替换为以下代码:
csharp
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
:
csharp
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:
csharp
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 定义的对象:
bash
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 类,使其包含机密字段:
bash
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
的文件:
csharp
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 模型:
bash
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 输出的格式。
csharp
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; }
}
运行效果:

当请求报文为
bash
{
"name": "test",
"isComplete": true
}
则返回
bash
{
"name": null,
"isComplete": true,
"nameField": null
}

当请求报文为
bash
{
"nameField": "Walk dog",
"isComplete": false
}
则返回
bash
{
"name": "Walk dog",
"isComplete": false,
"nameField": "Walk dog"
}
2、为终结点配置 JSON 序列化选项
若要为终结点配置序列化选项,请调用 Results.Json
并向其传递 JsonSerializerOptions
对象,如以下示例所示:
bash
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 的格式:
bash
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
以对应用的服务提供商注册所需的身份验证服务。
bash
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。
bash
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
会自动执行此操作。
bash
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
具体原理可以查阅最小 API 应用中的中间件。在某些情况(例如控制中间件顺序)下,需要显式注册身份验证和授权。 在以下示例中,身份验证中间件是在 CORS 中间件运行后运行的。
bash
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
选项可用于配置所使用的默认身份验证策略。
csharp
{
"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 属性来替代默认方案,如前面的示例所示。
方案名称用于唯一地标识身份验证策略,并在从配置解析身份验证选项时用作查找键,如以下示例所示:
csharp
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
终结点所需的策略提供。
bash
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
工具可用于创建持有者令牌。
bash
dotnet user-jwts create
注意
在项目上调用时,该工具会自动将与生成的令牌匹配的身份验证选项添加到 appsettings.json
。
可以通过多种自定义方式配置令牌。 例如,若要为上述代码中的授权策略所需的 admin
角色和 greetings_api
范围创建令牌,请执行以下操作:
bash
dotnet user-jwts create --scope "greetings_api" --role "admin"
然后,可以在所选的测试工具中将生成的令牌作为标头的一部分发送。 举个使用 curl 的例子:
bash
curl -i -H "Authorization: Bearer {token}" http://localhost:{port}/hello

bash
(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_api
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InNhbSIsInN1YiI6InNhbSIsImp0aSI6ImIwZmFmZWI0Iiwic2NvcGUiOiJncmVldGluZ3NfYXBpIiwicm9sZSI6ImFkbWluIiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MzQ0MiIsImh0dHBzOi8vbG9jYWxob3N0OjQ0MzgwIiwiaHR0cDovL2xvY2FsaG9zdDo1MDI2IiwiaHR0cHM6Ly9sb2NhbGhvc3Q6NzE1MyJdLCJuYmYiOjE3NDUwNTcwNDcsImV4cCI6MTc1MjkxOTQ0NywiaWF0IjoxNzQ1MDU3MDQ4LCJpc3MiOiJkb3RuZXQtdXNlci1qd3RzIn0.Rwgp9wO9PCLwJEiVgN-CGwlnLaAu0jhQXuzeo8Wh6Zg
bash
curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InNhbSIsInN1YiI6InNhbSIsImp0aSI6ImIwZmFmZWI0Iiwic2NvcGUiOiJncmVldGluZ3NfYXBpIiwicm9sZSI6ImFkbWluIiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MzQ0MiIsImh0dHBzOi8vbG9jYWxob3N0OjQ0MzgwIiwiaHR0cDovL2xvY2FsaG9zdDo1MDI2IiwiaHR0cHM6Ly9sb2NhbGhvc3Q6NzE1MyJdLCJuYmYiOjE3NDUwNTcwNDcsImV4cCI6MTc1MjkxOTQ0NywiaWF0IjoxNzQ1MDU3MDQ4LCJpc3MiOiJkb3RuZXQtdXNlci1qd3RzIn0.Rwgp9wO9PCLwJEiVgN-CGwlnLaAu0jhQXuzeo8Wh6Zg" http://localhost:5026/hello

可以看到,携带 token 请求可以正常访问 /hello
接口。完整代码如下:
bash
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();