refactor: move server rs workspace entries into crates

This commit is contained in:
2026-04-21 11:01:25 +08:00
parent 5a60ab3972
commit f6bf5f665e
47 changed files with 244 additions and 233 deletions

View File

@@ -0,0 +1,20 @@
[package]
name = "api-server"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
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"] }
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
http-body-util = "0.1"
tower = { version = "0.5", features = ["util"] }

View File

@@ -0,0 +1,96 @@
# api-server 主工程 crate 占位说明
日期:`2026-04-20`
## 1. crate 职责
`api-server` 是新后端的 Axum 主工程 crate后续负责
1. `main.rs` 启动入口
2. `Router` 装配
3. `with_state` 共享状态注入
4. 中间件挂载
5. `/healthz``/api/*`、SSE 与静态资源兼容层装配
6.`../../scripts/dev.ps1``../../scripts/dev.sh` 驱动的本地开发启动链路
7.`../../scripts/test.ps1``../../scripts/test.sh` 驱动的本地测试链路
8.`../../scripts/check.ps1``../../scripts/check.sh` 驱动的本地统一检查链路
9.`../../scripts/smoke.ps1``../../scripts/smoke.sh` 驱动的本地启动与协议冒烟链路
## 2. 当前阶段说明
当前目录已经完成以下基础骨架:
1. 目录占位
2. `Cargo.toml`
3. `src/main.rs`
4. `src/app.rs`
5. `src/state.rs`
6. `src/config.rs`
7. `src/logging.rs`
8. 基础 `TraceLayer` 挂载与 `tracing subscriber` 初始化
后续与本 crate 直接相关的任务包括:
1. [x] 接入统一日志与 tracing
2. [x] 接入 `request_id`
3. [x] 接入统一错误处理中间件
4. [x] 接入 response envelope
5. [x] 接入 `/healthz`
当前 tracing 约定:
1. 进程启动时统一初始化 `tracing subscriber`
2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`
3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。
当前 request context 约定:
1. 中间件优先读取来访 `x-request-id`,未提供时生成新的 UUID。
2. `request_id` 会统一写入请求 `extensions` 与请求头,供 tracing、错误处理中间件和响应头层复用。
3. 最终响应会回写同一个 `x-request-id`,保证调用方、日志链路和后续 envelope `meta.requestId` 可对齐。
当前错误处理中间件约定:
1. 对 Axum 默认产生的空 `4xx / 5xx` 响应,统一归一化为 legacy 兼容 JSON 错误体:`{ error: { code, message, details? } }`
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` 已按当前前端契约生成,响应头回写仍留给后续独立任务。
当前基础响应头约定:
1. 所有响应都会回写 `x-request-id`
2. 所有响应都会回写固定的 `x-api-version`,当前值与 body `meta.apiVersion` 保持一致。
3. 所有响应都会回写 `x-route-version`,当前阶段默认与 `x-api-version` 保持一致,后续再按路由粒度细分。
4. 所有响应都会回写 `x-response-time-ms`,值来源于 `RequestContext` 内记录的请求开始时间。
当前 `/healthz` 约定:
1. 路径固定为 `/healthz`
2. 裸响应继续返回 `{ ok: true, service: "genarrative-node-server" }`,保持与当前 Node 工程兼容。
3. 当请求携带 `x-genarrative-response-envelope` 时,`/healthz` 会返回标准 success envelope。
4. `x-request-id``x-api-version``x-route-version``x-response-time-ms` 会在 `/healthz` 响应中一并回写。
当前本地检查链路约定:
1. `../../scripts/check.ps1``../../scripts/check.sh` 统一串联 `cargo fmt --all --check``cargo clippy``cargo check``cargo test`
2. 默认检查整个 `server-rs` workspace确保后续多 crate 扩容时仍然保持统一口径。
3. 当只需聚焦单个 crate 时,可通过 `-Package``SERVER_RS_CHECK_PACKAGE` 收窄 `clippy / check / test` 目标。
4. `cargo fmt --all --check` 仍固定覆盖整个 workspace避免多 crate 下格式基线漂移。
当前本地 smoke 链路约定:
1. `../../scripts/smoke.ps1``../../scripts/smoke.sh` 会先构建 `api-server`,再拉起临时本地进程完成冒烟验证。
2. smoke 当前固定校验 `/healthz` 的 raw 响应、envelope 响应以及 `x-request-id``x-api-version``x-route-version``x-response-time-ms` 头。
3. smoke 通过后可作为“Axum 服务可独立启动且基础 contract 可联通”的本地自动化证据。
## 3. 边界约束
1. `api-server` 负责 HTTP、SSE、Cookie、Header、路由与协议装配。
2. 业务逻辑优先通过独立模块 crate 暴露能力,再由主工程组合。
3. 外部副作用通过 `platform-auth``platform-oss``platform-llm` 与各模块 crate 的应用层完成。
4. 不把领域规则直接堆在 handler 中。

View File

@@ -0,0 +1,141 @@
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<String>,
#[serde(rename = "routeVersion")]
route_version: &'static str,
operation: Option<String>,
#[serde(rename = "latencyMs")]
latency_ms: u64,
timestamp: String,
}
// 当前阶段先把成功响应 envelope helper 准备好,后续 `/healthz` 与业务 handler 会直接复用这里的输出逻辑。
#[allow(dead_code)]
pub fn json_success_body<T>(request_context: Option<&RequestContext>, data: T) -> Json<Value>
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<Value> {
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());
}
}

View File

@@ -0,0 +1,155 @@
use axum::{body::Body, extract::Extension, http::Request, middleware, routing::get, Router};
use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer};
use tracing::{info_span, Level};
use crate::{
error_middleware::normalize_error_response,
health::health_check,
request_context::{attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
state::AppState,
};
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
pub fn build_router(state: AppState) -> Router {
Router::new()
.route(
"/healthz",
get(|Extension(request_context): Extension<_>| async move {
health_check(Extension(request_context)).await
}),
)
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
.layer(middleware::from_fn(propagate_request_id_header))
// 当前阶段先统一挂接 HTTP tracing后续 request_id、响应头与错误中间件继续在这里扩展。
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| {
let request_id =
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
info_span!(
"http.request",
method = %request.method(),
uri = %request.uri(),
request_id = %request_id,
)
})
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(DefaultOnResponse::new().level(Level::INFO))
.on_failure(DefaultOnFailure::new().level(Level::ERROR)),
)
// request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。
.layer(middleware::from_fn(attach_request_context))
.with_state(state)
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
use crate::{config::AppConfig, state::AppState};
use super::build_router;
#[tokio::test]
async fn healthz_returns_legacy_compatible_payload_and_headers() {
let app = build_router(AppState::new(AppConfig::default()));
let response = app
.oneshot(
Request::builder()
.uri("/healthz")
.header("x-request-id", "req-health-legacy")
.body(Body::empty())
.expect("healthz request should build"),
)
.await
.expect("healthz request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok()),
Some("req-health-legacy")
);
assert_eq!(
response
.headers()
.get("x-api-version")
.and_then(|value| value.to_str().ok()),
Some("2026-04-08")
);
assert_eq!(
response
.headers()
.get("x-route-version")
.and_then(|value| value.to_str().ok()),
Some("2026-04-08")
);
assert!(response.headers().contains_key("x-response-time-ms"));
let body = response
.into_body()
.collect()
.await
.expect("healthz body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("healthz body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["service"],
Value::String("genarrative-node-server".to_string())
);
}
#[tokio::test]
async fn healthz_returns_standard_envelope_when_requested() {
let app = build_router(AppState::new(AppConfig::default()));
let response = app
.oneshot(
Request::builder()
.uri("/healthz")
.header("x-request-id", "req-health-envelope")
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("healthz request should build"),
)
.await
.expect("healthz request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("healthz body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("healthz body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["service"],
Value::String("genarrative-node-server".to_string())
);
assert_eq!(
payload["meta"]["requestId"],
Value::String("req-health-envelope".to_string())
);
}
}

View File

@@ -0,0 +1,52 @@
use std::{env, net::SocketAddr};
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
pub log_filter: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
bind_host: "127.0.0.1".to_string(),
bind_port: 3000,
log_filter: "info,tower_http=info".to_string(),
}
}
}
impl AppConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST") {
if !bind_host.trim().is_empty() {
config.bind_host = bind_host;
}
}
if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT") {
if let Ok(parsed_port) = bind_port.parse::<u16>() {
config.bind_port = parsed_port;
}
}
if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG") {
if !log_filter.trim().is_empty() {
config.log_filter = log_filter;
}
}
config
}
pub fn bind_socket_addr(&self) -> SocketAddr {
let address = format!("{}:{}", self.bind_host, self.bind_port);
address
.parse()
.unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3000)))
}
}

View File

@@ -0,0 +1,50 @@
use axum::{extract::Request, http::header::CONTENT_TYPE, middleware::Next, response::Response};
use tracing::{error, warn};
use crate::{
http_error::AppError,
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::<RequestContext>().cloned();
let response = next.run(request).await;
if !should_normalize_error_response(&response) {
return response;
}
let app_error = AppError::from_status(response.status());
let status = response.status();
let request_id = request_id.as_deref().unwrap_or("unknown");
if status.is_server_error() {
error!(
%request_id,
status = status.as_u16(),
error_code = app_error.code(),
"request failed"
);
} else {
warn!(
%request_id,
status = status.as_u16(),
error_code = app_error.code(),
"request failed"
);
}
app_error.into_response_with_context(request_context.as_ref())
}
fn should_normalize_error_response(response: &Response) -> bool {
let status = response.status();
if !(status.is_client_error() || status.is_server_error()) {
return false;
}
// 当前阶段只兜底框架默认的空错误响应或非 JSON 错误响应,避免覆盖后续业务 handler 主动构造的错误 body。
!response.headers().contains_key(CONTENT_TYPE)
}

View File

@@ -0,0 +1,14 @@
use axum::{extract::Extension, Json};
use serde_json::{json, Value};
use crate::{api_response::json_success_body, request_context::RequestContext};
pub async fn health_check(Extension(request_context): Extension<RequestContext>) -> Json<Value> {
json_success_body(
Some(&request_context),
json!({
"ok": true,
"service": "genarrative-node-server",
}),
)
}

View File

@@ -0,0 +1,76 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
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,
code: &'static str,
message: &'static str,
details: Option<Value>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ApiErrorPayload {
pub code: &'static str,
pub message: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl AppError {
pub fn from_status(status_code: StatusCode) -> Self {
let (code, message) = resolve_http_error(status_code);
Self {
status_code,
code,
message,
details: None,
}
}
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 {
self.into_response_with_context(None)
}
}
fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
match status_code {
StatusCode::BAD_REQUEST => ("BAD_REQUEST", "请求参数不合法"),
StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"),
StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"),
StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"),
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
_ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"),
_ => ("INTERNAL_SERVER_ERROR", "服务器内部错误"),
}
}

View File

@@ -0,0 +1,19 @@
use std::io;
use tracing_subscriber::{fmt, EnvFilter};
use crate::config::AppConfig;
// 统一在独立模块初始化 tracing避免入口层和后续测试入口重复散落 subscriber 配置。
pub fn init_tracing(config: &AppConfig) -> Result<(), io::Error> {
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(config.log_filter.as_str()))
.unwrap_or_else(|_| EnvFilter::new("info"));
fmt()
.with_env_filter(env_filter)
.with_target(true)
.compact()
.try_init()
.map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}")))
}

View File

@@ -0,0 +1,32 @@
mod api_response;
mod app;
mod config;
mod error_middleware;
mod health;
mod http_error;
mod logging;
mod request_context;
mod response_headers;
mod state;
use tokio::net::TcpListener;
use tracing::info;
use crate::{app::build_router, config::AppConfig, logging::init_tracing, state::AppState};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env();
init_tracing(&config)?;
let bind_address = config.bind_socket_addr();
let listener = TcpListener::bind(bind_address).await?;
let state = AppState::new(config);
let router = build_router(state);
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
axum::serve(listener, router).await
}

View File

@@ -0,0 +1,113 @@
use std::time::{Duration, Instant};
use axum::{
extract::Request,
http::{header::HeaderName, HeaderValue, Request as HttpRequest},
middleware::Next,
response::Response,
};
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,
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)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.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(),
operation,
Duration::ZERO,
wants_envelope,
));
// 统一把 request_id 写回请求头,方便后续 tracing、响应头与 envelope 层读取同一来源。
if let Ok(header_value) = HeaderValue::from_str(&request_id) {
request
.headers_mut()
.insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value);
}
next.run(request).await
}
pub fn resolve_request_id<B>(request: &HttpRequest<B>) -> Option<String> {
request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.or_else(|| {
request
.headers()
.get(X_REQUEST_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn wants_api_envelope<B>(request: &HttpRequest<B>) -> 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"))
}

View File

@@ -0,0 +1,49 @@
use axum::{
extract::Request,
http::{header::HeaderName, HeaderValue},
middleware::Next,
response::Response,
};
use crate::{
api_response::API_VERSION,
request_context::{resolve_request_id, RequestContext, X_REQUEST_ID_HEADER},
};
pub const API_VERSION_HEADER: &str = "x-api-version";
pub const RESPONSE_TIME_HEADER: &str = "x-response-time-ms";
pub const ROUTE_VERSION_HEADER: &str = "x-route-version";
pub async fn propagate_request_id_header(request: Request, next: Next) -> Response {
let request_id = resolve_request_id(&request);
let request_context = request.extensions().get::<RequestContext>().cloned();
let mut response = next.run(request).await;
if let Some(request_id) = request_id {
if let Ok(header_value) = HeaderValue::from_str(&request_id) {
response
.headers_mut()
.insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value);
}
}
if let Ok(header_value) = HeaderValue::from_str(API_VERSION) {
response.headers_mut().insert(
HeaderName::from_static(API_VERSION_HEADER),
header_value.clone(),
);
response
.headers_mut()
.insert(HeaderName::from_static(ROUTE_VERSION_HEADER), header_value);
}
if let Some(request_context) = request_context {
if let Ok(header_value) = HeaderValue::from_str(&request_context.elapsed().to_string()) {
response
.headers_mut()
.insert(HeaderName::from_static(RESPONSE_TIME_HEADER), header_value);
}
}
response
}

View File

@@ -0,0 +1,15 @@
use crate::config::AppConfig;
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
#[derive(Clone, Debug)]
pub struct AppState {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)]
pub config: AppConfig,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
Self { config }
}
}