XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

端点里使用框架 ​

这页讲不是操作契约的普通最小 API 端点怎样使用框架:它们在 UseXiHan() 之后处于管道的 Handle 阶段内,可以读调用上下文里的主体与租户、把失败结果交给宿主写成 Problem Details、挂上由 Authorize 阶段判定的授权要求,也可以选择绕过管道并改用 ASP.NET Core 认证方案 XiHan。读完这页,我们能判断一个端点该写成操作还是普通端点,并在普通端点里正确地拿到框架的东西。

前置知识:ASP.NET Core 宿主、执行管道与调用上下文。

为什么需要 ​

不是所有 HTTP 入口都适合写成操作:文件下载、重定向、健康面板、临时的诊断端点、SignalR Hub 都有自己的 HTTP 形态,硬套 IOperation<TRequest, TResponse> 反而别扭。但这些端点同样需要知道调用方是谁、属于哪个租户,失败时也应该和操作一样写出 Problem Details,而不是另起一套错误格式。

宿主把普通端点放进管道的 Handle 阶段:请求到达端点时 Authenticate、Tenant、Authorize 阶段已经执行完,端点只要从 HttpContext 取出调用上下文就能读到结果;把失败写回上下文,宿主在管道结束后统一翻译。真正不能进管道的长连接端点,再用 PipelineBypassMetadata 标出来,认证交给方案 XiHan。

端点在管道里的位置 ​

UseXiHan() 之后映射的端点,每个请求都先经过内核管道的前十个阶段,端点委托本身就是 Handle 阶段,端点返回后再走 Egress。于是在端点里:

要做的事入口说明
取当前调用上下文http.GetXiHanContext()返回 Maybe<InvocationContext>,没经过 UseXiHan() 时为无值
读主体与租户context.GetSecurity()SecurityContext:Principal(Id、IsAuthenticated、声明)与 Tenant(可空)
返回框架错误context.SetOutcome(Error)失败结果留在上下文,宿主写 Problem Details
取调用方语言的文案key.L()按上下文里协商出的区域翻译
要求已认证 / 权限 / 策略RequireXiHanAuthentication() 等写在端点元数据上,Authorize 阶段判定
读截止时间与取消context.Deadline、context.CancellationToken与操作里相同

端点委托里抛出的异常由内核翻译成 KERNEL_UNEXPECTED_001、HTTP 500;可恢复的失败不要抛,用 SetOutcome。

最小示例 ​

下面三个端点来自示例应用的 Program.cs,接 ASP.NET Core 宿主里的装配代码:

csharp
using SimpleApp;
using SimpleApp.Orders;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, SampleJsonContext.Default));
builder.AddXiHan(xihan => xihan.UseStandardProfile().UseFeature<OrdersFeature>());

var app = builder.Build();
app.UseXiHan();

// 读安全上下文:Authenticate 与 Tenant 阶段已经写好主体与租户
app.MapGet("/whoami", (HttpContext http) =>
{
    var security = http.GetXiHanContext().ValueOrThrow().GetSecurity();
    return new WhoAmI(security.Principal.Id, security.Principal.IsAuthenticated, security.Tenant?.Id);
}).ExcludeFromDescription();

// 返回框架错误:失败结果留在上下文里,管道结束后宿主写成 Problem Details
app.MapGet("/missing", (HttpContext http) =>
{
    http.GetXiHanContext().ValueOrThrow().SetOutcome(Error.NotFound("SAMPLES_NOTFOUND_001", SampleMessages.ResourceMissing));
    return Results.Empty;
});

// 授权要求:没有凭据的请求在 Authorize 阶段被拒,端点委托不执行
app.MapGet("/orders/stats", (OrderEventCounter counter) => new OrderStatistics(counter.Handled))
    .RequireXiHanAuthentication()
    .ExcludeFromDescription();

app.Run();

OrdersFeature、OrderEventCounter、OrderStatistics、SampleJsonContext 与 WhoAmI(记录:Principal、IsAuthenticated、Tenant)都来自示例应用(见示例说明);SampleMessages.ResourceMissing 是消息键常量 samples.resource.missing,各语言文案在 Localization/<区域>.json 里。WhoAmI 与 OrderStatistics 登记进源生成的序列化上下文。运行后:

bash
curl -s localhost:5000/whoami
curl -s localhost:5000/whoami -H "Authorization: ApiKey dev-alice-key"
curl -s localhost:5000/missing -H "Accept-Language: zh-CN"
curl -s -i localhost:5000/orders/stats

第一条返回匿名主体:{"principal":"","isAuthenticated":false,"tenant":null}(匿名主体的 ID 是空串);第二条返回 alice 与她的租户 acme;第三条返回 404 的 Problem Details,code 是 SAMPLES_NOTFOUND_001,detail 是中文文案;第四条返回 401,code 是 SECURITY_DENIED_001,响应头带 WWW-Authenticate: ApiKey。

读调用上下文 ​

HttpContext.GetXiHanContext() 返回 Maybe<InvocationContext>:请求经过了 UseXiHan() 时有值;绕过管道的端点、或者根本没调用 UseXiHan() 时为无值。端点确定自己在管道内时用 ValueOrThrow(),不确定时用 TryGetValue(out var context) 分支处理。

上下文里的内容按阶段写入,端点执行时都已就位:GetSecurity() 取 Authenticate 与 Tenant 阶段写好的 SecurityContext;TraceId、Baggage、Deadline 来自请求头;GetOperation() 在普通端点上为无值,因为它不是操作。读主体声明的写法见认证。

调用链深处拿不到 HttpContext 的服务(领域服务、仓储装饰器、消息处理器里的辅助类)不要依赖 HTTP,改用作用域服务 IInvocationAccessor.Current 取当前调用的上下文,它同样返回 Maybe<InvocationContext>,对 HTTP、gRPC、命令行与消息投递都成立,见调用上下文。

返回错误 ​

端点把失败写进调用上下文,由宿主统一翻译:

csharp
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Primitives;

app.MapGet("/reports/{id}", (string id, HttpContext http) =>
{
    // 结果写进上下文,Egress 由宿主翻译为 Problem Details;消息参数随错误一起带走,文案在 Egress 翻译
    http.GetXiHanContext().ValueOrThrow().SetOutcome(
        Error.NotFound("SHOP_NOTFOUND_002", "shop.report.not_found", MessageArguments.Of(("id", id))));
    return Results.Empty;
});

几条规则:

  • SetOutcome 接受 Result,Error 隐式转换成失败结果;调用后端点返回 Results.Empty 即可,宿主在响应还没开始写出时写 Problem Details。端点已经写出正文后再 SetOutcome,宿主保留端点的内容,只有结果记入日志与审计。
  • 状态码由错误码的 KIND 段映射(NOTFOUND 是 404),对照表见 HTTP 路由与载荷;detail 按调用方区域翻译,details 带标量参数。
  • Error 只携带消息键与参数,不带文案;键与各语言文件的约定见结果与错误处理与全球化和本地化。
  • 操作契约不需要这样做:HandleAsync 返回的 Result 就是调用结果。

绕过管道的端点没有上下文,要自己写错误时用 ProblemResponse:FromError(error, instance, traceId) 构造响应体,StatusCodeFor(error) 给出状态码,WriteTo(writer) 写出 JSON,结果与宿主写出的一致。

端点上的授权要求 ​

普通端点用三个约定扩展挂授权要求,UseXiHan() 把它放进上下文,Authorize 阶段判定:

扩展要求
RequireXiHanAuthentication()已认证
RequireXiHanPermission(permissionCode, resource)持有权限码;resource 可选,限定到某个资源
RequireXiHanPolicy(policyName, resource)命名策略允许;resource 可选
csharp
using XiHan.Framework.Hosting.AspNetCore.Endpoints;

app.MapGet("/orders/stats", (OrderEventCounter counter) => new OrderStatistics(counter.Handled))
    .RequireXiHanAuthentication();

app.MapGet("/orders/export", () => Results.Text("id,name"))
    .RequireXiHanPermission("orders.read");

判定由安全子系统完成:没有装配 security.authorization@1 时要求只进入上下文,不被判定。权限码必须由某个特性用 FeatureContext.AddPermission 登记;装配了授权能力时,UseXiHan() 在宿主建请求管道时核对全部端点的要求,未登记的权限码与不存在的策略让宿主启动失败(SECURITY_VALIDATION_004,一次列出全部)。端点上的要求优先于操作契约上的权限或策略。认证与授权的配置见认证与授权与权限。

这三个扩展与 ASP.NET Core 自己的 RequireAuthorization() 不是一回事:前者由内核管道的 Authorize 阶段判定,对管道内的端点用;后者由 ASP.NET Core 授权中间件判定,只给下文绕过管道的端点用。

端点与 OpenAPI 文档 ​

MapXiHanOpenApi() 生成的文档也会收录普通端点,条件是端点有 HTTP 方法元数据、带 Accepts 或 Produces 描述元数据之一,并且没有 ExcludeFromDescription()。最小 API 为返回类型自动加上 Produces 元数据,所以返回 JSON 对象的端点缺省会进文档,而文档对 JSON 类型只引用已登记操作的 Schema,不另做推导:引用不到时整份文档生成失败(PROTOCOLS_VALIDATION_010)。

因此示例里 /whoami、/orders/stats 这类返回自定义 JSON 的端点都加了 ExcludeFromDescription();/missing 返回 Results.Empty,没有响应类型,不进文档。响应正文确实与某个操作的请求或响应同形时,用 WithXiHanOperationSchema(request:, response:) 声明引用,端点就能进文档并共用组件。规则与写法见规范化接口文档。

绕过管道的端点 ​

SignalR Hub、Server-Sent Events 这类长连接端点不经内核管道:不受截止时间约束,内核停止时也不会被 503 打断,因此 Authenticate 阶段管不到它们。给端点加 PipelineBypassMetadata,UseXiHan() 看到它就直接放行,不建调用上下文;探针、OpenAPI 文档、.proto 与 gRPC 方法的端点都带着它。

这类端点需要认证时,Hosting.AspNetCore 提供 ASP.NET Core 认证方案 XiHan,把凭据交给内核已装配的同一个 IAuthenticationHandler,JWT、OpenID Connect、ApiKey 不用各接一遍:

  1. AddAuthentication().AddXiHan(...) 登记方案;浏览器的 WebSocket 与 EventSource 带不了请求头,把允许查询串令牌的路径前缀加进 QueryTokenPaths。
  2. UseAuthentication()、UseAuthorization() 放在 UseXiHan() 之前。
  3. 映射端点时用 PipelineBypassMetadata.BypassXiHanPipeline(...) 标注绕过,RequireAuthorization 的策略指定方案 XiHan。
csharp
using Microsoft.AspNetCore.Authorization;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Authentication;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);
builder.AddXiHan(xihan => xihan.UseStandardProfile());

builder.Services.AddSignalR();
// 查询串 access_token 只在 /hubs 下接受:查询串会进访问日志与代理日志
builder.Services.AddAuthentication().AddXiHan(options => options.QueryTokenPaths.Add("/hubs"));
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseXiHan();

var xihanScheme = new AuthorizeAttribute { AuthenticationSchemes = InvocationAuthenticationDefaults.AuthenticationScheme };
PipelineBypassMetadata.BypassXiHanPipeline(app.MapHub<NotificationHub>("/hubs/notifications")).RequireAuthorization(xihanScheme);

app.Run();

方案 XiHan 的规则:

项行为
生效范围只认带 PipelineBypassMetadata 的端点;普通请求由内核管道认证,方案对它们返回未认证,不重复认证
凭据先取 Authorization 头;没有时,请求路径(相对 PathBase)按路径段、不区分大小写落在 QueryTokenPaths 的某个前缀下,且 access_token 只有一个非空值,才作为 Bearer 凭据;其他路径的查询串令牌一律忽略
路径前缀不能为空也不能是 /,方案初始化时校验,违反时抛出 ArgumentException
认证交给方案名匹配的内核认证处理器;没有凭据是未认证,方案不支持、凭据无效都是 401,质询与管道一致
会话门禁认证成功后按登记顺序调用应用的 IInvocationAuthenticationFilter,任一返回失败即 401
主体转成认证类型为 XiHan 的 ClaimsPrincipal:声明保留原始类型名,另加 ClaimTypes.NameIdentifier 承载主体 ID 并作名称声明,角色声明类型取 XiHan:Security:RoleClaim(缺省 role)
登记AddXiHan 可以多次调用,方案只登记一次、各次的配置依次生效;不设为缺省方案

绕过管道的端点里 GetXiHanContext() 为无值。要在里面发起内核调用(如 Hub 方法调用操作),用 HttpInvocation.CreateContext(httpContext, services) 以请求建调用上下文,凭据按同一规则带上,调用经 IOperationInvoker 走完整管道时照常重新认证与授权。Hub 的完整写法、会话门禁与跨实例推送见实时通信。

常见问题 ​

这个入口该写成操作还是普通端点 ​

输入输出是 JSON 对象、要暴露给多种协议或进 OpenAPI 文档、需要按 Schema 校验的,写成操作(操作契约)。返回文件、文本、重定向,或者形态只在 HTTP 上有意义的(下载、SSE、Hub),写普通端点。普通端点拿不到 Validate 阶段的 Schema 校验与 Transaction 阶段按契约的只读判断,其余阶段一样。

GetXiHanContext().ValueOrThrow() 抛了异常 ​

说明请求没有经过 UseXiHan():要么端点映射在 UseXiHan() 之前,要么端点带了 PipelineBypassMetadata,要么根本没调用 UseXiHan()。核对中间件顺序;绕过管道的端点要用 HttpInvocation.CreateContext(httpContext, services) 自己建上下文。

加了 RequireXiHanPermission 之后应用启动失败 ​

权限码没有被任何特性用 AddPermission 登记,或者 RequireXiHanPolicy 引用的策略不存在。宿主在建请求管道时核对全部端点的要求,一次列出全部不合格项(SECURITY_VALIDATION_004)。在拥有这个权限的特性里登记权限码,或者改用已登记的码。

端点返回了 JSON,为什么 /openapi.json 整个 500 了 ​

文档对 JSON 类型只引用已登记操作的 Schema,端点返回的类型不是任何操作的请求或响应类型时报 PROTOCOLS_VALIDATION_010。给端点加 ExcludeFromDescription(),或者用 WithXiHanOperationSchema 声明它与哪个操作同形,或者把它改写成操作。

UseAuthentication() 为什么要放在 UseXiHan() 之前 ​

方案 XiHan 只服务绕过管道的端点,这类端点在 UseXiHan() 里被直接放行,认证与授权中间件放在前面,它们的握手在进入 UseXiHan() 之前就已经完成判定。放在后面时,管道内的普通请求也会在 Handle 阶段再经过一次 ASP.NET Core 认证中间件,而它们已经由内核管道认证过了。按约定放在 UseXiHan() 之前,顺序只有这一种。

相关包与下一步 ​

Released under The MIT License