From f7663076a5bf99904498b05eeeefc12abdca1382 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Apr 2026 01:28:52 +0800 Subject: [PATCH] build: add api server response envelope helpers --- .../01_M0_M2_FOUNDATION_AND_AUTH.md | 3 +- server-rs/Cargo.lock | 53 +++++++ server-rs/apps/api-server/Cargo.toml | 1 + server-rs/apps/api-server/README.md | 8 +- server-rs/apps/api-server/src/api_response.rs | 129 ++++++++++++++++++ .../apps/api-server/src/error_middleware.rs | 7 +- server-rs/apps/api-server/src/http_error.rs | 43 +++--- server-rs/apps/api-server/src/main.rs | 1 + .../apps/api-server/src/request_context.rs | 58 +++++++- 9 files changed, 273 insertions(+), 30 deletions(-) create mode 100644 server-rs/apps/api-server/src/api_response.rs diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index 17fe88d6..922dbf70 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -100,7 +100,8 @@ 交付物:[../server-rs/apps/api-server/src/request_context.rs](../server-rs/apps/api-server/src/request_context.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs) - [x] 接入统一错误处理中间件 交付物:[../server-rs/apps/api-server/src/http_error.rs](../server-rs/apps/api-server/src/http_error.rs)、[../server-rs/apps/api-server/src/error_middleware.rs](../server-rs/apps/api-server/src/error_middleware.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs) -- [ ] 接入当前项目兼容的 response envelope +- [x] 接入当前项目兼容的 response envelope + 交付物:[../server-rs/apps/api-server/src/api_response.rs](../server-rs/apps/api-server/src/api_response.rs)、[../server-rs/apps/api-server/src/request_context.rs](../server-rs/apps/api-server/src/request_context.rs)、[../server-rs/apps/api-server/src/http_error.rs](../server-rs/apps/api-server/src/http_error.rs) - [ ] 接入 `x-request-id` - [ ] 接入 `x-api-version` - [ ] 接入 `x-route-version` diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d0dc8741..21570637 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -24,6 +24,7 @@ dependencies = [ "axum", "serde", "serde_json", + "time", "tokio", "tower-http", "tracing", @@ -113,6 +114,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -386,6 +396,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "once_cell" version = "1.21.4" @@ -404,6 +420,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -596,6 +618,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.52.1" diff --git a/server-rs/apps/api-server/Cargo.toml b/server-rs/apps/api-server/Cargo.toml index cfadb214..87b59b37 100644 --- a/server-rs/apps/api-server/Cargo.toml +++ b/server-rs/apps/api-server/Cargo.toml @@ -9,6 +9,7 @@ axum = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } +time = { version = "0.3", features = ["formatting"] } tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/server-rs/apps/api-server/README.md b/server-rs/apps/api-server/README.md index 1e8e1d57..3c70c48b 100644 --- a/server-rs/apps/api-server/README.md +++ b/server-rs/apps/api-server/README.md @@ -30,7 +30,7 @@ 1. [x] 接入统一日志与 tracing 2. [x] 接入 `request_id` 3. [x] 接入统一错误处理中间件 -4. [ ] 接入 response envelope +4. [x] 接入 response envelope 5. [ ] 接入 `/healthz` 当前 tracing 约定: @@ -51,6 +51,12 @@ 2. 已经带 `content-type` 的业务错误响应不会被覆盖,避免抢走后续 response envelope 的职责。 3. 统一错误日志会复用当前请求的 `request_id`,便于后续和响应头、envelope 元信息串联。 +当前 response envelope 约定: + +1. `RequestContext` 已记录 `request_id`、请求开始时间、默认 `operation` 与 envelope 协商结果。 +2. `json_success_body(...)` / `json_error_body(...)` 会根据 `x-genarrative-response-envelope` 自动在“裸数据 / 标准 envelope / legacy error + meta”之间切换。 +3. `meta.apiVersion`、`meta.requestId`、`meta.routeVersion`、`meta.operation`、`meta.latencyMs`、`meta.timestamp` 已按当前前端契约生成,响应头回写仍留给后续独立任务。 + ## 3. 边界约束 1. `api-server` 负责 HTTP、SSE、Cookie、Header、路由与协议装配。 diff --git a/server-rs/apps/api-server/src/api_response.rs b/server-rs/apps/api-server/src/api_response.rs new file mode 100644 index 00000000..b22988d0 --- /dev/null +++ b/server-rs/apps/api-server/src/api_response.rs @@ -0,0 +1,129 @@ +use axum::Json; +use serde::Serialize; +use serde_json::{json, Value}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +use crate::{http_error::ApiErrorPayload, request_context::RequestContext}; + +pub const API_VERSION: &str = "2026-04-08"; + +#[derive(Debug, Serialize)] +struct ApiResponseMeta { + #[serde(rename = "apiVersion")] + api_version: &'static str, + #[serde(rename = "requestId", skip_serializing_if = "Option::is_none")] + request_id: Option, + #[serde(rename = "routeVersion")] + route_version: &'static str, + operation: Option, + #[serde(rename = "latencyMs")] + latency_ms: u64, + timestamp: String, +} + +// 当前阶段先把成功响应 envelope helper 准备好,后续 `/healthz` 与业务 handler 会直接复用这里的输出逻辑。 +#[allow(dead_code)] +pub fn json_success_body(request_context: Option<&RequestContext>, data: T) -> Json +where + T: Serialize, +{ + if let Some(context) = request_context { + if context.wants_envelope() { + return Json(json!({ + "ok": true, + "data": data, + "error": null, + "meta": build_api_response_meta(Some(context)), + })); + } + } + + Json(serde_json::to_value(data).unwrap_or(Value::Null)) +} + +pub fn json_error_body( + request_context: Option<&RequestContext>, + error: &ApiErrorPayload, +) -> Json { + let meta = build_api_response_meta(request_context); + + if request_context.is_some_and(RequestContext::wants_envelope) { + return Json(json!({ + "ok": false, + "data": null, + "error": error, + "meta": meta, + })); + } + + Json(json!({ + "error": error, + "meta": meta, + })) +} + +fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiResponseMeta { + ApiResponseMeta { + api_version: API_VERSION, + request_id: request_context.map(|context| context.request_id().to_string()), + route_version: API_VERSION, + operation: request_context.map(|context| context.operation().to_string()), + latency_ms: request_context + .map(RequestContext::elapsed) + .unwrap_or_default(), + timestamp: OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::request_context::RequestContext; + use std::time::Duration; + + fn build_request_context(wants_envelope: bool) -> RequestContext { + RequestContext::new( + "req-test".to_string(), + "GET /test".to_string(), + Duration::from_millis(12), + wants_envelope, + ) + } + + #[test] + fn success_body_returns_standard_envelope_when_requested() { + let request_context = build_request_context(true); + let body = json_success_body(Some(&request_context), json!({ "ok": "value" })).0; + + assert_eq!(body["ok"], Value::Bool(true)); + assert_eq!(body["data"]["ok"], Value::String("value".to_string())); + assert_eq!(body["meta"]["requestId"], Value::String("req-test".to_string())); + assert_eq!(body["meta"]["routeVersion"], Value::String(API_VERSION.to_string())); + } + + #[test] + fn success_body_returns_raw_payload_when_envelope_not_requested() { + let request_context = build_request_context(false); + let body = json_success_body(Some(&request_context), json!({ "ok": "value" })).0; + + assert_eq!(body["ok"], Value::String("value".to_string())); + assert!(body.get("meta").is_none()); + } + + #[test] + fn error_body_returns_legacy_shape_without_envelope_header() { + let request_context = build_request_context(false); + let error = ApiErrorPayload { + code: "NOT_FOUND", + message: "资源不存在", + details: None, + }; + let body = json_error_body(Some(&request_context), &error).0; + + assert_eq!(body["error"]["code"], Value::String("NOT_FOUND".to_string())); + assert_eq!(body["meta"]["requestId"], Value::String("req-test".to_string())); + assert!(body.get("ok").is_none()); + } +} diff --git a/server-rs/apps/api-server/src/error_middleware.rs b/server-rs/apps/api-server/src/error_middleware.rs index 0de3b608..a974f167 100644 --- a/server-rs/apps/api-server/src/error_middleware.rs +++ b/server-rs/apps/api-server/src/error_middleware.rs @@ -2,17 +2,18 @@ use axum::{ extract::Request, http::header::CONTENT_TYPE, middleware::Next, - response::{IntoResponse, Response}, + response::Response, }; use tracing::{error, warn}; use crate::{ http_error::AppError, - request_context::resolve_request_id, + request_context::{resolve_request_id, RequestContext}, }; pub async fn normalize_error_response(request: Request, next: Next) -> Response { let request_id = resolve_request_id(&request); + let request_context = request.extensions().get::().cloned(); let response = next.run(request).await; if !should_normalize_error_response(&response) { @@ -39,7 +40,7 @@ pub async fn normalize_error_response(request: Request, next: Next) -> Response ); } - app_error.into_response() + app_error.into_response_with_context(request_context.as_ref()) } fn should_normalize_error_response(response: &Response) -> bool { diff --git a/server-rs/apps/api-server/src/http_error.rs b/server-rs/apps/api-server/src/http_error.rs index dcb1317e..1987b3f2 100644 --- a/server-rs/apps/api-server/src/http_error.rs +++ b/server-rs/apps/api-server/src/http_error.rs @@ -1,11 +1,12 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde::Serialize; use serde_json::Value; +use crate::{api_response::json_error_body, request_context::RequestContext}; + #[derive(Debug)] pub struct AppError { status_code: StatusCode, @@ -14,17 +15,12 @@ pub struct AppError { details: Option, } -#[derive(Debug, Serialize)] -struct LegacyApiErrorBody { - error: LegacyApiErrorPayload, -} - -#[derive(Debug, Serialize)] -struct LegacyApiErrorPayload { - code: &'static str, - message: &'static str, +#[derive(Clone, Debug, Serialize)] +pub struct ApiErrorPayload { + pub code: &'static str, + pub message: &'static str, #[serde(skip_serializing_if = "Option::is_none")] - details: Option, + pub details: Option, } impl AppError { @@ -42,19 +38,26 @@ impl AppError { pub fn code(&self) -> &'static str { self.code } + + pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response { + let status_code = self.status_code; + let payload = self.to_payload(); + + (status_code, json_error_body(request_context, &payload)).into_response() + } + + fn to_payload(&self) -> ApiErrorPayload { + ApiErrorPayload { + code: self.code, + message: self.message, + details: self.details.clone(), + } + } } impl IntoResponse for AppError { fn into_response(self) -> Response { - let body = LegacyApiErrorBody { - error: LegacyApiErrorPayload { - code: self.code, - message: self.message, - details: self.details, - }, - }; - - (self.status_code, Json(body)).into_response() + self.into_response_with_context(None) } } diff --git a/server-rs/apps/api-server/src/main.rs b/server-rs/apps/api-server/src/main.rs index 40713eb7..ba69c81e 100644 --- a/server-rs/apps/api-server/src/main.rs +++ b/server-rs/apps/api-server/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod api_response; mod config; mod error_middleware; mod http_error; diff --git a/server-rs/apps/api-server/src/request_context.rs b/server-rs/apps/api-server/src/request_context.rs index 23a7f283..353f4cd9 100644 --- a/server-rs/apps/api-server/src/request_context.rs +++ b/server-rs/apps/api-server/src/request_context.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, Instant}; + use axum::{ extract::Request, http::{header::HeaderName, HeaderValue, Request as HttpRequest}, @@ -6,25 +8,57 @@ use axum::{ }; use uuid::Uuid; +pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; pub const X_REQUEST_ID_HEADER: &str = "x-request-id"; // 当前阶段先把请求级元信息统一挂到 extensions,后续响应头、envelope 与错误处理中间件继续复用。 #[derive(Clone, Debug)] pub struct RequestContext { request_id: String, + operation: String, + request_started_at: Instant, + wants_envelope: bool, } impl RequestContext { - pub fn new(request_id: String) -> Self { - Self { request_id } + pub fn new( + request_id: String, + operation: String, + elapsed_seed: Duration, + wants_envelope: bool, + ) -> Self { + Self { + request_id, + operation, + request_started_at: Instant::now() + .checked_sub(elapsed_seed) + .unwrap_or_else(Instant::now), + wants_envelope, + } } pub fn request_id(&self) -> &str { &self.request_id } + + pub fn operation(&self) -> &str { + &self.operation + } + + pub fn wants_envelope(&self) -> bool { + self.wants_envelope + } + + pub fn elapsed(&self) -> u64 { + self.request_started_at + .elapsed() + .as_millis() + .min(u64::MAX as u128) as u64 + } } pub async fn attach_request_context(mut request: Request, next: Next) -> Response { + let wants_envelope = wants_api_envelope(&request); let request_id = request .headers() .get(X_REQUEST_ID_HEADER) @@ -33,10 +67,14 @@ pub async fn attach_request_context(mut request: Request, next: Next) -> Respons .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| Uuid::new_v4().to_string()); + let operation = format!("{} {}", request.method(), request.uri()); - request - .extensions_mut() - .insert(RequestContext::new(request_id.clone())); + request.extensions_mut().insert(RequestContext::new( + request_id.clone(), + operation, + Duration::ZERO, + wants_envelope, + )); // 统一把 request_id 写回请求头,方便后续 tracing、响应头与 envelope 层读取同一来源。 if let Ok(header_value) = HeaderValue::from_str(&request_id) { @@ -63,3 +101,13 @@ pub fn resolve_request_id(request: &HttpRequest) -> Option { .map(ToOwned::to_owned) }) } + +fn wants_api_envelope(request: &HttpRequest) -> bool { + request + .headers() + .get(API_RESPONSE_ENVELOPE_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .map(str::to_lowercase) + .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "v1" | "envelope")) +}