规范化接口文档(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/schemas | Schema 里的 $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 |
| gRPC | protobuf 消息与线格式,.proto 由同一份描述生成 | MapXiHanProto(),GET /operations.proto,见 gRPC |
| MCP | tool 的 inputSchema / outputSchema | MapXiHanMcp(),tools/list,见 MCP |
最小示例
- 引用
XiHan.Framework.Protocols.Http(Profiles.Full已包含),UseFeature<HttpProtocolFeature>()。 - 在
app.UseXiHan()与app.MapXiHanOperations()之后调用app.MapXiHanOpenApi()。
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();curl -s localhost:5000/openapi.json -H "Accept-Language: en-US"示例应用的文档里 /orders 一节(截取):
{
"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。
文档选项
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 | 空,不写 servers | OpenApiServer(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 宿主的「区域协商与本地化」:
{
"orders.create.title": "下单",
"orders.create.description": "按名称与金额下单。",
"permissions.orders.write.name": "下单权限",
"openapi.tags.orders.name": "订单"
}分组
OpenApiOptions.Groups 声明分组,每组另发一份文档:
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> 的类型与操作的请求、响应类型自动匹配。
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 为例:
<!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() 之前启用静态文件,页面本身就不经内核管道:
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 请求:
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);| 参数 | 说明 |
|---|---|
options | OpenApiOptions,为 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。
相关包与下一步
- 相关包:Protocols.Http、Hosting.AspNetCore、Kernel、Kernel.Generators
- 下一页:结果与错误处理,文档里的
default响应就是它的 Problem Details 形状 - 同一份契约的其他载体:gRPC、MCP、命令行宿主
- 相关章节:操作契约、HTTP 路由与载荷、端点里使用框架、授权与权限、全球化和本地化
- 规范:HTTP 载体规范的 OpenAPI 一节、RFC-0011《操作契约与 XGI》
