refactor: move server rs workspace entries into crates
This commit is contained in:
141
server-rs/crates/api-server/src/api_response.rs
Normal file
141
server-rs/crates/api-server/src/api_response.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
155
server-rs/crates/api-server/src/app.rs
Normal file
155
server-rs/crates/api-server/src/app.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
52
server-rs/crates/api-server/src/config.rs
Normal file
52
server-rs/crates/api-server/src/config.rs
Normal 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)))
|
||||
}
|
||||
}
|
||||
50
server-rs/crates/api-server/src/error_middleware.rs
Normal file
50
server-rs/crates/api-server/src/error_middleware.rs
Normal 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)
|
||||
}
|
||||
14
server-rs/crates/api-server/src/health.rs
Normal file
14
server-rs/crates/api-server/src/health.rs
Normal 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",
|
||||
}),
|
||||
)
|
||||
}
|
||||
76
server-rs/crates/api-server/src/http_error.rs
Normal file
76
server-rs/crates/api-server/src/http_error.rs
Normal 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", "服务器内部错误"),
|
||||
}
|
||||
}
|
||||
19
server-rs/crates/api-server/src/logging.rs
Normal file
19
server-rs/crates/api-server/src/logging.rs
Normal 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}")))
|
||||
}
|
||||
32
server-rs/crates/api-server/src/main.rs
Normal file
32
server-rs/crates/api-server/src/main.rs
Normal 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
|
||||
}
|
||||
113
server-rs/crates/api-server/src/request_context.rs
Normal file
113
server-rs/crates/api-server/src/request_context.rs
Normal 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"))
|
||||
}
|
||||
49
server-rs/crates/api-server/src/response_headers.rs
Normal file
49
server-rs/crates/api-server/src/response_headers.rs
Normal 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
|
||||
}
|
||||
15
server-rs/crates/api-server/src/state.rs
Normal file
15
server-rs/crates/api-server/src/state.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user