1use axum::body::Body;
2use axum::extract::State;
3use axum::extract::{FromRequestParts, Request};
4use axum::http::header::HeaderName;
5use axum::http::request::Parts;
6use axum::middleware::Next;
7use axum::response::Response;
8use chrono::{DateTime, Utc};
9use platform_core::{
10 ActorResolutionRequest, AppContext, CorrelationId, IdGenerator, RequestContext, RequestId,
11 UuidGenerator, generate_trace_context,
12 story_events::{
13 HttpRequestStoryEventRecord, http_request_story_creation, http_request_story_event_id,
14 insert_http_request_story_projection,
15 },
16 trace_context_from_traceparent,
17};
18use std::time::Instant;
19use tracing::Instrument;
20
21const REQUEST_ID_HEADER: &str = "x-request-id";
22const CORRELATION_ID_HEADER: &str = "x-correlation-id";
23const AUTHORIZATION_HEADER: &str = "authorization";
24const COOKIE_HEADER: &str = "cookie";
25const TRACEPARENT_HEADER: &str = "traceparent";
26
27#[derive(Debug, Clone)]
28pub struct HttpRequestContext(pub RequestContext);
29
30impl std::ops::Deref for HttpRequestContext {
31 type Target = RequestContext;
32
33 fn deref(&self) -> &Self::Target {
34 &self.0
35 }
36}
37
38pub async fn request_context_middleware(
39 State(ctx): State<AppContext>,
40 mut request: Request<Body>,
41 next: Next,
42) -> Response {
43 let started_at = Instant::now();
44 let started_at_utc = Utc::now();
45 let request_id = header_value(request.headers(), REQUEST_ID_HEADER)
46 .unwrap_or_else(|| UuidGenerator.new_id("req"));
47 let correlation_id = header_value(request.headers(), CORRELATION_ID_HEADER)
48 .unwrap_or_else(|| UuidGenerator.new_id("corr"));
49 let actor = ctx
50 .actor_resolver
51 .resolve_actor(ActorResolutionRequest {
52 authorization: authorization_header(request.headers()),
53 cookie: cookie_header(request.headers()),
54 })
55 .await;
56 let trace = traceparent_header(request.headers())
57 .and_then(|value| trace_context_from_traceparent(&value))
58 .unwrap_or_else(generate_trace_context);
59 let method = request.method().clone();
60 let path = request.uri().path().to_owned();
61
62 let mut context = RequestContext {
63 request_id: RequestId::new(request_id),
64 correlation_id: CorrelationId::new(correlation_id),
65 trace,
66 actor,
67 tenant_id: None,
68 causation_id: None,
69 };
70 context.causation_id = Some(http_request_story_event_id(&context));
71
72 request
73 .extensions_mut()
74 .insert(HttpRequestContext(context.clone()));
75
76 let span = tracing::info_span!(
77 "http_request",
78 request_id = %context.request_id.0,
79 correlation_id = %context.correlation_id.0,
80 lenso.correlation_id = %context.correlation_id.0,
81 lenso.story_id = %context.correlation_id.0,
82 lenso.execution.kind = "http_request",
83 lenso.execution.name = %format!("{} {}", method.as_str(), path.as_str()),
84 otel.trace_id = context.trace.trace_id.as_deref().unwrap_or(""),
85 otel.parent_span_id = context.trace.span_id.as_deref().unwrap_or(""),
86 http_method = %method,
87 http_path = %path,
88 );
89
90 let mut response = next.run(request).instrument(span).await;
91 record_http_request_story(
92 ctx.db.clone(),
93 context.clone(),
94 method.as_str(),
95 path.as_str(),
96 response.status().as_u16(),
97 response.headers().get("x-lenso-error-code"),
98 started_at,
99 started_at_utc,
100 );
101 response.headers_mut().insert(
102 REQUEST_ID_HEADER,
103 context.request_id.0.parse().expect("valid request id"),
104 );
105 response.headers_mut().insert(
106 CORRELATION_ID_HEADER,
107 context
108 .correlation_id
109 .0
110 .parse()
111 .expect("valid correlation id"),
112 );
113 response
114}
115
116fn record_http_request_story(
117 pool: platform_core::DbPool,
118 request_ctx: RequestContext,
119 method: &str,
120 path: &str,
121 status_code: u16,
122 error_code_header: Option<&axum::http::HeaderValue>,
123 started_at: Instant,
124 started_at_utc: DateTime<Utc>,
125) {
126 let error_code = error_code_header
127 .and_then(|value| value.to_str().ok())
128 .filter(|value| !value.is_empty())
129 .map(ToOwned::to_owned);
130 let duration_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
131 let record = HttpRequestStoryEventRecord {
132 method: method.to_owned(),
133 path: path.to_owned(),
134 status_code,
135 error_code,
136 creation: http_request_story_creation(path, status_code),
137 started_at: started_at_utc,
138 completed_at: Utc::now(),
139 duration_ms,
140 };
141
142 tokio::spawn(async move {
143 if let Err(error) = insert_http_request_story_projection(&pool, &request_ctx, record).await
144 {
145 tracing::warn!(
146 error = ?error,
147 request_id = %request_ctx.request_id.0,
148 correlation_id = %request_ctx.correlation_id.0,
149 "failed to write HTTP request story projection"
150 );
151 }
152 });
153}
154
155impl<S> FromRequestParts<S> for HttpRequestContext
156where
157 S: Send + Sync,
158{
159 type Rejection = crate::ApiErrorResponse;
160
161 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
162 parts
163 .extensions
164 .get::<HttpRequestContext>()
165 .cloned()
166 .ok_or_else(|| {
167 platform_core::AppError::new(
168 platform_core::ErrorCode::Internal,
169 "Request context is missing",
170 )
171 .into()
172 })
173 }
174}
175
176fn header_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
177 let name = HeaderName::from_static(match name {
178 REQUEST_ID_HEADER => REQUEST_ID_HEADER,
179 CORRELATION_ID_HEADER => CORRELATION_ID_HEADER,
180 _ => unreachable!("known request context header"),
181 });
182
183 headers
184 .get(name)
185 .and_then(|value| value.to_str().ok())
186 .filter(|value| !value.is_empty())
187 .map(ToOwned::to_owned)
188}
189
190fn authorization_header(headers: &axum::http::HeaderMap) -> Option<String> {
191 let name = HeaderName::from_static(AUTHORIZATION_HEADER);
192 headers
193 .get(name)
194 .and_then(|value| value.to_str().ok())
195 .filter(|value| !value.is_empty())
196 .map(ToOwned::to_owned)
197}
198
199fn cookie_header(headers: &axum::http::HeaderMap) -> Option<String> {
200 let name = HeaderName::from_static(COOKIE_HEADER);
201 headers
202 .get(name)
203 .and_then(|value| value.to_str().ok())
204 .filter(|value| !value.is_empty())
205 .map(ToOwned::to_owned)
206}
207
208fn traceparent_header(headers: &axum::http::HeaderMap) -> Option<String> {
209 let name = HeaderName::from_static(TRACEPARENT_HEADER);
210 headers
211 .get(name)
212 .and_then(|value| value.to_str().ok())
213 .filter(|value| !value.is_empty())
214 .map(ToOwned::to_owned)
215}