XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

规范化接口文档(OpenAPI) ​

这页讲怎样从操作契约发布 OpenAPI 文档:MapXiHanOpenApi() 从哪些输入生成文档、文档里的权限与认证方案怎样表达、面向人的文案从哪里来、怎样把一个大应用的文档拆成几组、普通端点怎样进文档、怎样接 Scalar 或 Swagger UI 这类查看器,以及不启动 HTTP 服务怎样导出同一份文档。读完这页,我们能发布一份与实际路由逐字一致、带权限标注、按调用方语言翻译的接口文档,并把它交给前端与生成客户端代码的工具。

前置知识:操作契约与 HTTP 路由与载荷。

为什么需要 ​

手写或用注解维护的接口文档总会和实现脱节:路由改了文档没改,字段加了必填没标,权限要求只写在代码里前端看不见。文档一旦不可信,前端就只能靠猜或者读后端源码。

框架的文档不是第二套描述。MapXiHanOpenApi() 读的是 MapXiHanOperations() 用的同一个操作登记表、Kernel.Generators 在编译期生成的同一份请求 / 响应 JSON Schema、同一个权限码注册表与宿主的端点表,路径与方法用同一条推导规则算出来。契约改了,文档跟着变,不需要人手同步;gRPC 的 .proto 与 MCP 的 tool 描述也来自这份 Schema,三种载体的字段定义天然一致。

文档从哪里来 ​

MapXiHanOpenApi(options, pattern) 发布 OpenAPI 3.1 文档,pattern 缺省 /openapi.json。文档在第一次请求时生成并缓存,这时全部特性已装配、登记表与端点表都已定型。文档端点带 PipelineBypassMetadata,不经内核管道、不鉴权;需要限制访问时由宿主自己处理(见常见问题)。

文档内容来源
paths 与方法与 MapXiHanOperations() 同一条路由推导(含覆盖与前缀)
operationId、tags操作 ID;资源段
路径参数、查询参数、请求体请求 Schema:模板里的 {name} 是必填路径参数,GET / DELETE 的其余属性是查询参数,POST / PUT / PATCH 的请求 Schema 整体作 requestBody
成功响应状态码同 HTTP 映射(create 201,其余 200),正文是响应 Schema
default 响应application/problem+json,引用 components/schemas/ProblemDetails
components/schemasSchema 里的 $defs 提升而来;字符串枚举也是一个组件
x-xihan-stability稳定性不是 Stable 的操作
x-xihan-permission / x-xihan-policy契约或端点上的权限码 / 策略名
summary、description、字段说明消息目录与 XML 文档注释,按区域翻译

没有 Schema 的操作只发布路径、方法与 operationId。只给 gRPC、MCP 的操作与 Stability.Internal 的操作不进文档。

同一份 Schema 的三个载体:

载体从 Schema 得到什么入口
OpenAPI参数、请求体、响应与组件MapXiHanOpenApi(),GET /openapi.json
gRPCprotobuf 消息与线格式,.proto 由同一份描述生成MapXiHanProto(),GET /operations.proto,见 gRPC
MCPtool 的 inputSchema / outputSchemaMapXiHanMcp(),tools/list,见 MCP

最小示例 ​

  1. 引用 XiHan.Framework.Protocols.Http(Profiles.Full 已包含),UseFeature<HttpProtocolFeature>()。
  2. 在 app.UseXiHan() 与 app.MapXiHanOperations() 之后调用 app.MapXiHanOpenApi()。
csharp
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Profiles.Standard;
using XiHan.Framework.Protocols.Http;
using XiHan.Framework.Protocols.Http.OpenApi;
using XiHan.Framework.Protocols.Http.Routing;

var builder = WebApplication.CreateBuilder(args);
builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature<HttpProtocolFeature>()
    .UseFeature<OrdersFeature>());

var app = builder.Build();
app.UseXiHan();
app.MapXiHanOperations();
app.MapXiHanOpenApi();      // GET /openapi.json
app.Run();
bash
curl -s localhost:5000/openapi.json -H "Accept-Language: en-US"

示例应用的文档里 /orders 一节(截取):

json
{
  "paths": {
    "/orders": {
      "post": {
        "operationId": "orders.create",
        "tags": ["orders"],
        "summary": "Place an order",
        "description": "Place an order with a name and an amount.\n\nRequires permission orders.write (Place orders).",
        "x-xihan-stability": "Experimental",
        "x-xihan-permission": "orders.write"
      }
    }
  },
  "components": {
    "x-xihan-permissions": {
      "orders.read": { "code": "orders.read", "displayName": "Read orders" },
      "orders.write": { "code": "orders.write", "displayName": "Place orders" }
    }
  }
}

summary 与 description 取自示例应用消息目录里的 orders.create.title 与 orders.create.description,权限要求那一行由生成器按区域附上。把 Accept-Language 换成 zh-CN,文案就是中文,响应头 Content-Language: zh-CN。

文档选项 ​

csharp
using XiHan.Framework.Protocols.Http.OpenApi;

var openApi = new OpenApiOptions
{
    Title = "Orders API",
    Version = "v1",
    Servers = [new OpenApiServer("https://api.example.com", "生产")],
};
app.MapXiHanOpenApi(openApi);
属性缺省说明
Title宿主的应用名info.title
Version框架版本info.version,写应用自己的接口版本
Servers空,不写 serversOpenApiServer(Url, Description) 列表
Groups空分组,见下文

MapXiHanOpenApi 的第二个参数 pattern 决定文档地址,缺省 /openapi.json;分组文档的地址由它派生。

权限与认证方案 ​

契约有 Permission 的操作带 x-xihan-permission(权限码),有 Policy 的带 x-xihan-policy(策略名),都没有时不写。components 下的 x-xihan-permissions 按权限码(序数排序)列出权限定义,收录范围是文档里已发布操作引用的权限码与内核 PermissionRegistry 登记的全部权限的并集:只在 gRPC、MCP 上暴露的操作或端点级 RequireXiHanPermission 用到的权限也在里面,前端据此隐藏无权的入口、生成授权界面;被引用却没登记的只有 code。权限码不是 OAuth scope,所以不占用标准的 security / securitySchemes。

装配了以 Bearer 方案工作的认证 Provider(Security.Jwt、Security.OpenIdConnect)时,文档发布 components.securitySchemes.bearer(type: http、scheme: bearer、bearerFormat: JWT)并挂全局 security: [{ "bearer": [] }],没有权限也没有策略要求的操作标 security: []。ApiKey 等其他方案不发布。查看器据此显示「授权」按钮,把令牌带进试调请求。权限码与策略的定义见授权与权限。

文案、标签与字段说明 ​

文档里面向人的文字都来自消息目录,装配了本地化时按 Accept-Language 协商出的区域分别生成、分别缓存,响应带 Content-Language,缺省区域是 en-US;没有装配本地化时只有一份文档,没有这些文案。键里的 - 一律换成 _。

键用于
<操作 ID>.title操作的 summary;MCP tools/list 的 title 用同一个键
<操作 ID>.description操作的 description;MCP 的 description 用同一个键
permissions.<权限码>.name / .description授权行里的显示名,x-xihan-permissions 的 displayName / description;没有时取权限定义的
openapi.tags.<资源段>.name / .description顶层 tags 的 x-displayName / description;name 仍是资源码
openapi.groups.<组名>.name / .description分组文档的 info.title / info.description 与 x-tagGroups 的组名
openapi.schemas.<Schema 名>.description覆盖根或组件的说明
openapi.schemas.<Schema 名>.properties.<属性名>覆盖属性的说明

summary 与 description 各自没有就不写,不互相顶替。授权要求由生成器附在 description 末尾,空一行:权限是「需要权限 <码>(<显示名>)」,命名策略是「需要策略 <名>」,authenticated 策略是「需要登录」,文案在框架目录 protocols.http.openapi_*。应用的 .description 里不要再手写权限要求。

字段说明的源语言文案是 DTO 的 XML 文档注释:Kernel.Generators 把类型与属性的 <summary>(位置记录取主构造参数的 <param>)折进 Schema 的 description,参数、请求体与成功响应据此写说明,成功响应没有根说明时写按区域的「成功」。项目要开启 GenerateDocumentationFile。某个区域要换文案时才写 openapi.schemas.* 覆盖,Schema 名与属性名都转成 snake_case(ConstraintRuleDto → constraint_rule_dto,ruleType → rule_type)。

应用的文案放在 Localization/<区域>.json,登记方式见 ASP.NET Core 宿主的「区域协商与本地化」:

json
{
  "orders.create.title": "下单",
  "orders.create.description": "按名称与金额下单。",
  "permissions.orders.write.name": "下单权限",
  "openapi.tags.orders.name": "订单"
}

分组 ​

OpenApiOptions.Groups 声明分组,每组另发一份文档:

csharp
using XiHan.Framework.Protocols.Http.OpenApi;

var openApi = new OpenApiOptions
{
    Groups =
    [
        // 名字、排序、所属特性的清单 ID、额外标签(让没有操作的普通端点入组)
        new OpenApiGroup("orders", 1, ["xihan.samples.orders"]),
        new OpenApiGroup("system", 2, [], ["health"]),
    ],
};
app.MapXiHanOpenApi(openApi);   // /openapi.json、/openapi/orders.json、/openapi/system.json

foreach (var link in app.GetXiHanOpenApiDocuments(openApi))
{
    // 交给文档查看器登记:link.Name、link.Title、link.Url
    app.Logger.LogInformation("OpenAPI document {Name}: {Url}", link.Name, link.Url);
}
规则说明
组名匹配 ^[a-z][a-z0-9-]*$、互不重复且不能是 all(全量文档的名字),违反时构造 OpenApiOptions 就抛出 ArgumentException
操作入组按归属特性的清单 ID;一个特性只能属于一个组
普通端点入组按标签;组的标签是组内操作的资源段加额外标签
全量文档带 x-tagGroups,按排序再按名字列出各组,一个标签只归排序靠前的那一组,没进任何组的标签归入最后的「其他」组
分组文档地址pattern 去掉 .json 后接 /<组名>.json,只含本组的操作、端点、组件与引用到的权限码

GetXiHanOpenApiDocuments(options, pattern) 列出全部文档的名字、缺省区域的标题与地址,全量文档排在最前,参数要与 MapXiHanOpenApi 一致。

普通端点 ​

宿主里不是操作的最小 API 端点也能进文档。收录条件:有 HTTP 方法元数据,带 Accepts 或 Produces 描述元数据之一,没有 ExcludeFromDescription()。SignalR Hub、探针、重定向、静态资源没有描述元数据,不会进文档。路由参数写成 string 类型的路径参数;WithName 作 operationId,WithSummary、WithDescription、WithTags 照搬;RequireXiHan* 的要求写成 x-xihan-permission / x-xihan-policy。

请求体与响应按内容类型写:multipart/form-data 是含 format: binary 属性(file,多文件为 files)的对象,表单是对象,text/* 与 string 是字符串,其余二进制是 format: binary 的字符串。JSON 类型不做第二套 Schema 推导,只引用已登记操作的根 Schema:用 WithXiHanOperationSchema(request:, response:) 显式声明同哪个操作的请求或响应,没有声明时按 Accepts<T> / Produces<T> 的类型与操作的请求、响应类型自动匹配。

csharp
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Protocols.Http.OpenApi;

app.MapGet("/orders/{id}/receipt", (Guid id) => Results.Text($"receipt {id}"))
    .WithName("orders.receipt")
    .WithTags("orders")
    .Produces<string>(StatusCodes.Status200OK, "text/plain")
    .RequireXiHanPermission("orders.read");

// 响应正文同 orders.create 的响应 Schema:文档里引用同一个组件,不另推导
app.MapGet("/orders/sample", () => new OrderResponse(Guid.NewGuid(), "widget", 3, DateTimeOffset.UtcNow))
    .WithName("orders.sample")
    .Produces<OrderResponse>()
    .WithXiHanOperationSchema(response: "orders.create");

OrderResponse 是示例应用里 orders.create 的响应类型。

JSON 类型引用不到任何操作的 Schema 时,文档生成以 PROTOCOLS_VALIDATION_010 失败;普通端点与操作共用一条路由时以 PROTOCOLS_CONFLICT_002 失败。两种失败都在第一次请求文档时暴露,修正办法是补 WithXiHanOperationSchema、登记对应的操作,或给端点加 ExcludeFromDescription()。端点自身的写法见端点里使用框架。

接入查看器 ​

框架只发布文档,不内置文档界面。Scalar、Swagger UI、Redoc 这类查看器都只需要一个文档地址,把 /openapi.json(分组时是 GetXiHanOpenApiDocuments 列出的每个地址)交给它们即可。最简单的做法是放一个静态页面,在页面里引用查看器的脚本并指向文档地址,以 Scalar 为例:

html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <title>Orders API</title>
  </head>
  <body>
    <script id="api-reference" data-url="/openapi.json"></script>
    <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
  </body>
</html>

把页面放在 wwwroot/docs/index.html,并在 UseXiHan() 之前启用静态文件,页面本身就不经内核管道:

csharp
app.UseStaticFiles();
app.UseXiHan();

Swagger UI 的做法相同,把它的 url 配置项指向 /openapi.json。用 Scalar.AspNetCore、Swashbuckle.AspNetCore.SwaggerUI 这类 ASP.NET Core 集成包也可以,它们的登记方式按各自的文档,框架这一侧只提供文档地址与 GetXiHanOpenApiDocuments 给出的分组列表。查看器试调时发出的请求与普通请求一样经过内核管道:带 Authorization 头才能调用有权限要求的操作,Accept-Language 决定错误文案的语言。

生成客户端代码的工具(如 Kiota、NSwag、openapi-generator)直接读 /openapi.json;文档里的 operationId 是操作 ID,生成的方法名与后端一致。

不经宿主导出文档 ​

OpenApiDocumentSource.FromServices(services, options, endpoints, locale, group) 从容器组装与 MapXiHanOpenApi 相同的输入,OpenApiDocumentBuilder.Build(source) 生成 UTF-8 字节,与 /openapi.json 逐字节一致。测试与导出工具用这一对入口,不必发 HTTP 请求:

csharp
using Microsoft.AspNetCore.Routing;
using XiHan.Framework.Protocols.Http.OpenApi;

// 应用已启动(特性装配完成、端点表定型),options 与 MapXiHanOpenApi 用的同一个
var endpoints = ((IEndpointRouteBuilder)app).DataSources.SelectMany(static source => source.Endpoints).ToList();
var document = OpenApiDocumentBuilder.Build(OpenApiDocumentSource.FromServices(app.Services, openApi, endpoints, "zh-CN"));
await File.WriteAllBytesAsync("openapi.zh-CN.json", document);
参数说明
optionsOpenApiOptions,为 null 时标题取应用名、版本取框架版本
endpoints宿主的端点表,普通端点与认证方案由此而来
locale文案的区域;为 null 时取本地化的默认区域
group分组名;为 null 时是全量文档,不存在的组名抛出 ArgumentException

把导出的文件提交进仓库并在 CI 里与新生成的文件比对,契约变更就会出现在代码评审的差异里。另一个重载 OpenApiDocumentBuilder.Build(registry, options, permissions, title, version) 只看登记表,不含普通端点、认证方案与文案,适合单元测试里核对路由推导。测试里启动应用的写法见单元与集成测试。

常见问题 ​

文档端点不鉴权,怎样限制访问 ​

文档端点带 PipelineBypassMetadata,与端点里使用框架里绕过管道的端点是同一类:MapXiHanOpenApi() 返回 IEndpointConventionBuilder,可以用 ASP.NET Core 自己的 RequireAuthorization 配合认证方案 XiHan 保护,或者在反向代理上只对内网放行。生产环境不需要对外公开文档时,也可以只在开发环境调用 MapXiHanOpenApi()。

改了消息目录或权限定义,文档没变 ​

文档在第一次请求时生成并缓存,按区域分别缓存,进程不重启不会重新生成。开发时重启应用即可;配置目录热更新改变的是选项,不会让文档缓存失效。

怎样让某个操作不出现在文档里 ​

三种办法:Expose 里去掉 Http(它就不在 HTTP 上发布,自然也不进文档);Stability = Stability.Internal(任何协议都不发布);或者把它放进一个单独的分组,前端只登记其他组的文档。没有「只隐藏文档、保留端点」的开关,文档与路由始终一致。

为什么请求体的属性名是 camelCase,而我的 C# 属性是 PascalCase ​

Schema 的属性名按序列化上下文的命名策略生成,示例用的是 JsonKnownNamingPolicy.CamelCase。文档与实际绑定用的是同一份序列化元数据,所以客户端按文档发请求一定能被绑定;要换命名风格,改 JsonSourceGenerationOptions,文档随之变化。

gRPC 客户端和 MCP 客户端看到的字段会和 OpenAPI 不一致吗 ​

不会。三者读同一份由 Kernel.Generators 生成的 Schema:OpenAPI 把它提升成组件,gRPC 由它推出 protobuf 消息(字段名转 lower_snake_case,json_name 保持属性名),MCP 原样作为 inputSchema / outputSchema。改一个 DTO,三处同时变,见 gRPC与 MCP。

相关包与下一步 ​

Released under The MIT License