Skip to content

Commit 52cf031

Browse files
feat(openapi): 引入#[endpoint]与OpenAPI集成,完善示例与文档 (#127)
* feat(openapi): 实现完整的OpenAPI/Swagger支持库 - 创建 silent-openapi 库,提供 OpenAPI 3.0 支持 - 实现 SwaggerUiMiddleware 和 SwaggerUiHandler 两种集成方式 - 支持自动路径参数转换 (<id:i64> → {id}) - 添加完整的错误处理和类型安全 - 包含16个单元测试,全部通过 - 提供用户管理API和简单示例 - 修复运行时冲突问题,使用 serve() 方法 基于 utoipa 4.2 和 utoipa-swagger-ui 6.0 实现 * fix(openapi): 修复Swagger UI中间件404问题 - 使用 root_hook() 替代 hook() 来注册全局中间件 - 修复 simple_example.rs 和 openapi-test 的运行时冲突 - 更改 form_parse() 为 json_parse() 以正确处理JSON请求体 - 移除调试输出,清理代码 现在所有示例都能正常访问 Swagger UI 文档界面 * feat(openapi): 实现完整的OpenAPI/Swagger支持库 - 创建 silent-openapi 库,提供 OpenAPI 3.0 支持 - 实现 SwaggerUiMiddleware 和 SwaggerUiHandler 两种集成方式 - 支持自动路径参数转换 (<id:i64> → {id}) - 添加完整的错误处理和类型安全 - 包含16个单元测试,全部通过 - 提供用户管理API和简单示例 - 修复运行时冲突问题,使用 serve() 方法 基于 utoipa 4.2 和 utoipa-swagger-ui 6.0 实现 # Conflicts: # Cargo.toml * fix(openapi): 修复Swagger UI中间件404问题 - 使用 root_hook() 替代 hook() 来注册全局中间件 - 修复 simple_example.rs 和 openapi-test 的运行时冲突 - 更改 form_parse() 为 json_parse() 以正确处理JSON请求体 - 移除调试输出,清理代码 现在所有示例都能正常访问 Swagger UI 文档界面 * chore(pre-commit): 通过钩子校验并修复问题 - 格式化所有 Rust 源码以通过 cargo fmt - deny.toml 忽略 RUSTSEC-2024-0370(utoipa-gen 间接依赖,暂无安全升级),附注释说明 - .cargo/config.toml 设置 build.target-dir=target,确保构建产物在工作区内 验证:cargo fmt -- --check、cargo check --all、cargo clippy -D warnings 通过 * feat(api): 完善 OpenAPI/Swagger 集成与示例\n\n- 新增 Route→OpenAPI 便捷 API(RouteOpenApiExt::to_openapi)\n- 默认生成 operationId/tags/path 参数,优化基础文档\n- 完善 PathItem 合并逻辑,避免同一路径多方法覆盖\n- 增加 OpenAPI 安全定义:add_bearer_auth + set_global_security\n- Swagger UI 增加 tryItOut 开关(Middleware/Handler 均支持 with_options)\n- 示例:openapi-test 使用处理器方式挂载 /docs,演示 401/403;新增 security_example\n- 文档:补充 swagger-dev 路线图与 P0 需求,README 增加生产建议\n\n说明:暂不处理 UI 静态资源本地化;405 问题后续单独跟进 * fix(api): 使用处理器方式挂载 Swagger 并修复 openapi.json 路由\n\n- examples/openapi-test 改用 SwaggerUiHandler 挂载\n- 明确注册 /docs、/docs/openapi.json、/docs/<path:**> 的 GET/HEAD\n- 验证 /docs 与 /docs/openapi.json 均可用,避免 404/405 * fix(api): 规范化OpenAPI路径前缀并完善路径参数生成 - convert_path_format 确保路径以'/'开头且空路径映射为'/' - 支持从 <name:type> 与 {name} 提取路径参数并生成 schema - 映射常见类型:i32/i64/u32/u64/uuid,默认回退为 string - 解决 Swagger 页面接口 URL 拼接异常与路径参数无法填写问题 * feat(api): 支持将SwaggerUiHandler直接挂载为路由 我添加 SwaggerUiHandler::into_route,便于在 <ui_path> 下直接注册 GET/HEAD。 我实现 RouterAdapt,使处理器可被 Route::append 直接使用。 自动注册 <ui_path>、<ui_path>/openapi.json、<ui_path>/<path:**>。 * refactor(core): simplify swagger route mounting using into_route method * feat(api): 引入 #[endpoint] 属性宏并支持注释生成 OpenAPI 说明 - 新增 crate silent-openapi-macros,提供 #[endpoint],从属性参数或文档注释提取 summary/description;保持 .get(get_xxx) 注册风格 - 适配签名:Request、任意萃取器 Args、(Request, Args);返回类型 T: Into<Response> - silent-openapi 路由收集时按 handler 指针读取注册的文档元信息;缺省回退为 <METHOD> <path> - 示例 openapi-test:get_hello 返回 String,get_user 返回 User;Swagger UI 仍通过 into_route 挂载 - 更新需求文档,记录属性宏方案与回退规则 * feat(api): 完善 #[endpoint] 宏适配与OpenAPI集成 - 适配萃取器签名:支持 Request、单萃取器 Args、(Request, Args) - 保持 .get(get_xxx) 挂载风格:生成端点常量并实现 IntoRouteHandler - 路由收集兼容 utoipa v5:修正 PathItem 构建与合并逻辑 - Swagger UI 升级至 5.17.14:支持 OpenAPI 3.1,修复版本校验报错 - cargo deny:允许 Zlib 许可证,检查通过 - 示例 openapi-test:get_hello 返回 String,get_user 返回 User * feat(api): endpoint宏识别返回类型并在openapi.json中生成响应内容 - 宏解析 Ok(T):String/str → text/plain,自定义类型 → application/json 并引用组件 - 路由构建 Operation 时注入 content,OpenApiDoc 追加占位组件以解析 schema 引用 - 保持 .get(get_xxx) 注册风格与注释 summary/description * feat(api): 基于ToSchema生成完整schema并应用到openapi.json - 在 endpoint 宏中为自定义返回类型注册 ToSchema 构建器 - 路由生成文档时注入响应 content,并通过 ToSchema 生成组件 schema - 新增 OpenApiDoc::apply_registered_schemas,整合注册的 schema 到 components 中 * feat(openapi): remove unused list_registered_json_types import from route module * test(api): 补充middleware/handler/route/doc单测并为宏增加token检验测试 - middleware: CORS、自定义 openapi.json 路径、重定向校验 - handler: wildcard 资源匹配、HEAD 回退校验 - route/doc: 响应 content 注入、合并逻辑、ToSchema 组件应用 - macros: 将实现提取为 endpoint_impl,新增对结构与注册调用的字符串断言测试 - silent-openapi 行覆盖率约 63.7% * test(api): 提升覆盖率至约82%并丰富边界用例 - middleware: 非匹配路径、资源404、自定义openapi路径、index.html头部校验 - route: 空/相对路径归一化、同方法合并先者优先 - handler: index.html渲染分支校验 - schema: servers/security/paths/pretty JSON 校验 - 宏:保留前次宏测试,验证端点与响应元信息注册 * chore(ci): 同步通过pre-commit后的变更并提交 包含 workspace Cargo.toml/.gitignore 更新、宏与openapi代码的小幅清理;本地 pre-commit、clippy、deny、tests 已通过。 * docs: 拆分openAPI指南至独立文档openapi.md - 从萃取器文档中分离OpenAPI内容,形成清晰的两份指南 - 删除开发阶段的swagger-dev说明,避免混淆 - openapi.md 覆盖依赖、ToSchema、#[endpoint]、生成文档、挂载UI与最佳实践
1 parent 39f56dd commit 52cf031

File tree

24 files changed

+3872
-54
lines changed

24 files changed

+3872
-54
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ AGENTS.md
1414
# ignore Cargo.lock at repo root
1515
Cargo.lock
1616
out/
17+
.cargo-home
18+
.pre-commit-cache
19+
*.sock

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[workspace]
2-
default-members = ["silent"]
3-
members = ["silent", "benchmark", "examples/*", "xtask"]
2+
default-members = ["silent", "silent-openapi", "silent-openapi-macros"]
3+
members = ["silent", "silent-openapi", "silent-openapi-macros", "examples/*", "benchmark", "xtask"]
44
resolver = "2"
55

66
[workspace.package]

deny.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ feature-depth = 1
7272
ignore = [
7373
"rsa",
7474
"RUSTSEC-2023-0071",
75+
# proc-macro-error is unmaintained; transitively pulled by utoipa-gen.
76+
# Upstream has no safe upgrade yet (RUSTSEC-2024-0370). We rely on utoipa
77+
# for OpenAPI generation only; risk is limited to build-time proc-macro diagnostics.
78+
"RUSTSEC-2024-0370",
7579
#"RUSTSEC-0000-0000",
7680
#{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
7781
#"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
@@ -100,6 +104,7 @@ allow = [
100104
"CC0-1.0",
101105
"ISC",
102106
"OpenSSL",
107+
"Zlib",
103108
]
104109
# The confidence threshold for detecting a license from license text.
105110
# The higher the value, the more closely the license text must be to the

docs/openapi.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
## OpenAPI 使用指南(结合萃取器风格)
2+
3+
本文档介绍如何在 Silent 工程中集成 OpenAPI(接口文档)并与萃取器风格的路由配合使用。目标是用最少的标注获得可读、可交互(Swagger UI)的接口文档,并在生产环境具备可控的启停能力。
4+
5+
### 1. 依赖
6+
- 工作空间已包含 `silent-openapi`
7+
- 业务 crate 常用依赖:
8+
- `serde = { version = "1", features = ["derive"] }`
9+
- `utoipa = { version = "5", features = ["derive"] }``silent-openapi` 已复导出常用 trait)
10+
11+
### 2. 定义数据模型(ToSchema)
12+
```rust
13+
use serde::{Serialize, Deserialize};
14+
use silent_openapi::ToSchema;
15+
16+
#[derive(Serialize, Deserialize, ToSchema)]
17+
struct User { id: u64, name: String }
18+
```
19+
20+
### 3. 定义处理器与路由(配合萃取器)
21+
推荐使用 `#[endpoint]` 宏为处理器注册文档说明与响应类型:
22+
```rust
23+
use silent::prelude::*;
24+
use silent_openapi::{endpoint, ToSchema};
25+
26+
#[endpoint(summary = "获取用户", description = "根据路径参数 id 返回用户信息")]
27+
async fn get_user(Path(id): Path<u64>) -> Result<User> {
28+
Ok(User { id, name: format!("User {}", id) })
29+
}
30+
31+
#[endpoint(summary = "健康检查", description = "返回 ok 字符串")]
32+
async fn health(_req: Request) -> Result<String> { Ok("ok".into()) }
33+
34+
let routes = Route::new("")
35+
.append(Route::new("users/<id:u64>").get(get_user))
36+
.append(Route::new("health").get(health));
37+
```
38+
39+
返回类型与 OpenAPI 响应映射:
40+
- `Result<String>` / `Result<&str>``text/plain` 响应
41+
- `Result<T>`(非 `Response` 且带 `ToSchema`)→ `application/json`,并自动生成/合并 `components.schemas` 中的 `T`
42+
- `Result<Response>` → 保留原样(默认 200 响应,无 content)
43+
44+
### 4. 生成 OpenAPI 文档
45+
`silent-openapi` 提供 `RouteOpenApiExt::to_openapi`
46+
```rust
47+
use silent_openapi::{OpenApiDoc, RouteOpenApiExt};
48+
49+
let openapi = routes.to_openapi("My API", "1.0.0");
50+
// 叠加安全策略(可选)
51+
let openapi = OpenApiDoc::from_openapi(openapi)
52+
.add_bearer_auth("bearerAuth", Some("JWT Bearer token"))
53+
.set_global_security("bearerAuth", &[])
54+
.into_openapi();
55+
```
56+
57+
路径与参数规则:
58+
- Silent 路由 `<id:u64>` / `<name>` 会被自动转换为 OpenAPI `{id}` / `{name}`
59+
- 基于萃取器的请求体(Json/Form)参数暂不自动生成 requestBody,请结合 utoipa 的 `#[utoipa::path(...)]` 按需补充
60+
61+
### 5. 挂载 Swagger UI(文档页面)
62+
使用处理器方式更易于集成:
63+
```rust
64+
use silent_openapi::{SwaggerUiHandler, SwaggerUiOptions};
65+
66+
let swagger = SwaggerUiHandler::with_options("/docs", openapi, SwaggerUiOptions { try_it_out_enabled: true })
67+
.expect("create swagger ui");
68+
let app = Route::new("")
69+
.append(swagger.into_route()) // /docs 与 /docs/openapi.json
70+
.append(routes);
71+
72+
Server::new().bind("127.0.0.1:8080".parse().unwrap()).serve(app).await;
73+
```
74+
75+
生产建议:
76+
- 通过 `SwaggerUiOptions { try_it_out_enabled: false }` 关闭交互;或仅在非生产环境挂载 UI
77+
- 如需鉴权与 CORS 控制,可在网关层或中间件层实现
78+
79+
### 6. 文档说明的来源
80+
- `#[endpoint(summary = "...", description = "...")]` 优先使用属性参数
81+
- 未显式指定时,从处理函数的 `///` 文档注释首行/剩余行提取 `summary/description`
82+
- 若仍为空,则按规则回退:
83+
- `summary`: `<METHOD> <path>`
84+
- `description`: `Handler for <METHOD> <path>`
85+
86+
### 7. 与萃取器的配合
87+
- 路由注册保持零泛型:`.get(get_user)`
88+
- 支持三种处理器形态:
89+
- `fn(Request) -> _`
90+
- `fn(Args) -> _`(如 `Path<T> / Query<T> / Json<T>`
91+
- `fn(Request, Args) -> _`
92+
- 常见参数(路径 `<id:u64>`)已自动体现在 OpenAPI 的 `parameters` 中;其余复杂输入(如 Query 结构体、JSON 请求体)推荐用 utoipa 标注补充
93+
94+
### 8. 常见问题
95+
- UI 404:本版本非主页资源使用 CDN,除 `/docs``/docs/index.html` 外的静态资源默认 404
96+
- 版本兼容:该 UI 版本已支持 OpenAPI 3.1;若外部工具要求 3.0 可按需降级 utoipa
97+
98+
### 9. 最佳实践清单
99+
- 模型统一 `#[derive(Serialize, Deserialize, ToSchema)]`
100+
- 处理器统一加 `#[endpoint]`,便于自动生成响应映射与说明
101+
- `Route::to_openapi(...)` + `OpenApiDoc::from_openapi(...)` 叠加安全、服务器配置
102+
- 生产环境关闭 Try it out 或仅保留 openapi.json 导出

examples/openapi-test/Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[package]
2+
edition = "2021"
3+
name = "openapi-test"
4+
publish = false
5+
version = "0.1.0"
6+
7+
[dependencies]
8+
serde = {version = "1.0", features = ["derive"]}
9+
silent = {path = "../../silent"}
10+
silent-openapi = {path = "../../silent-openapi"}
11+
tokio = {version = "1.0", features = ["full"]}
12+
utoipa = "5"

examples/openapi-test/src/main.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
use serde::{Deserialize, Serialize};
2+
use silent::extractor::Path;
3+
use silent::header;
4+
use silent::prelude::*;
5+
use silent_openapi::{
6+
endpoint, OpenApiDoc, RouteOpenApiExt, SwaggerUiHandler, SwaggerUiOptions, ToSchema,
7+
};
8+
9+
#[derive(Serialize, Deserialize, ToSchema)]
10+
struct User {
11+
id: u64,
12+
name: String,
13+
}
14+
15+
#[derive(Serialize, Deserialize, ToSchema)]
16+
struct ErrorResponse {
17+
code: String,
18+
message: String,
19+
}
20+
21+
// 本示例将使用路由自动生成 OpenAPI,再补充安全定义
22+
#[endpoint(summary = "获取问候", description = "返回 \"Hello, OpenAPI!\"")]
23+
async fn get_hello(_req: Request) -> Result<String> {
24+
Ok("Hello, OpenAPI!".into())
25+
}
26+
27+
#[endpoint(summary = "获取用户", description = "根据路径参数 id 返回用户信息")]
28+
async fn get_user(Path(id): Path<u64>) -> Result<User> {
29+
Ok(User {
30+
id,
31+
name: format!("User {}", id),
32+
})
33+
}
34+
35+
// 受保护端点:无 Authorization 返回 401,带特殊 token 返回 403,其它通过
36+
#[endpoint(summary = "受保护示例", description = "演示 401/403 与成功的不同响应")]
37+
async fn get_protected(req: Request) -> Result<Response> {
38+
let auth = req
39+
.headers()
40+
.get(header::AUTHORIZATION)
41+
.and_then(|v| v.to_str().ok())
42+
.map(|s| s.to_string());
43+
match auth {
44+
None => {
45+
let body = ErrorResponse {
46+
code: "UNAUTHORIZED".into(),
47+
message: "missing Authorization".into(),
48+
};
49+
Ok(Response::json(&body).with_status(StatusCode::UNAUTHORIZED))
50+
}
51+
Some(value) if value.contains("forbidden") => {
52+
let body = ErrorResponse {
53+
code: "FORBIDDEN".into(),
54+
message: "token not allowed".into(),
55+
};
56+
Ok(Response::json(&body).with_status(StatusCode::FORBIDDEN))
57+
}
58+
Some(_) => Ok(Response::text("ok")),
59+
}
60+
}
61+
62+
#[tokio::main]
63+
async fn main() -> Result<()> {
64+
logger::fmt().init();
65+
66+
// 先构建业务路由
67+
let routes = Route::new("")
68+
.get(get_hello)
69+
.append(Route::new("users").append(Route::new("<id:u64>").get(get_user)))
70+
.append(Route::new("protected").get(get_protected));
71+
72+
// 基于路由生成 OpenAPI,并补充 Bearer 安全定义与全局 security
73+
let openapi = routes.to_openapi("Test API", "1.0.0");
74+
let openapi = OpenApiDoc::from_openapi(openapi)
75+
.add_bearer_auth("bearerAuth", Some("JWT Bearer token"))
76+
.set_global_security("bearerAuth", &[])
77+
.into_openapi();
78+
79+
// 可选:关闭 Try it out(生产环境常用)
80+
let options = SwaggerUiOptions {
81+
try_it_out_enabled: true,
82+
};
83+
let swagger = SwaggerUiHandler::with_options("/docs", openapi, options)
84+
.expect("Failed to create Swagger UI");
85+
86+
// 直接将 SwaggerUiHandler 转为可挂载的路由树并追加
87+
let routes = Route::new("").append(swagger.into_route()).append(routes);
88+
89+
println!("🚀 Server starting!");
90+
println!("📖 API docs: http://localhost:8080/docs");
91+
println!("🔗 Endpoints:");
92+
println!(" GET /hello");
93+
println!(" GET /users/{{id}}");
94+
println!(" GET /protected - 401/403 示例: Authorization: Bearer <token>");
95+
println!(" - 无头: 401; token 含 'forbidden': 403; 其他: 200");
96+
97+
let addr = "127.0.0.1:8080".parse().expect("Invalid address");
98+
Server::new().bind(addr).serve(routes).await;
99+
Ok(())
100+
}

silent-openapi-macros/Cargo.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
authors.workspace = true
3+
description = "Proc-macros for silent-openapi"
4+
edition.workspace = true
5+
homepage.workspace = true
6+
license.workspace = true
7+
name = "silent-openapi-macros"
8+
readme.workspace = true
9+
repository.workspace = true
10+
version.workspace = true
11+
12+
[lib]
13+
proc-macro = true
14+
15+
[dependencies]
16+
convert_case = "0.8"
17+
proc-macro2 = "1"
18+
quote = "1"
19+
syn = {version = "2", features = ["full"]}

0 commit comments

Comments
 (0)