use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use axum::extract::FromRequestParts;
use bytes::Bytes;
use http::request::Parts;
use http::{HeaderMap, HeaderName, HeaderValue, Request, Response, header};
use http_body::Body;
use http_body_util::{BodyExt, Full, combinators::UnsyncBoxBody};
use tower::{Layer, Service};
use jsonapi_http::{JSON_API_MEDIA_TYPE, stamp_error_ids_in_bytes};
const DEFAULT_HEADER: HeaderName = HeaderName::from_static("x-request-id");
type BoxError = Box<dyn std::error::Error + Send + Sync>;
type ResponseBody = UnsyncBoxBody<Bytes, BoxError>;
#[derive(Debug, Clone)]
pub struct RequestId(pub String);
impl<S> FromRequestParts<S> for RequestId
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(id) = parts.extensions.get::<RequestId>() {
return Ok(id.clone());
}
let from_header = header_id(&parts.headers, &DEFAULT_HEADER).unwrap_or_default();
Ok(RequestId(from_header))
}
}
#[derive(Clone, Debug)]
pub struct RequestIdLayer {
header_name: HeaderName,
generate: bool,
}
impl RequestIdLayer {
#[must_use]
pub fn new() -> Self {
Self {
header_name: DEFAULT_HEADER,
generate: false,
}
}
#[must_use]
pub fn header_name(mut self, name: HeaderName) -> Self {
self.header_name = name;
self
}
#[cfg(feature = "uuid")]
#[cfg_attr(docsrs, doc(cfg(feature = "uuid")))]
#[must_use]
pub fn generate(mut self) -> Self {
self.generate = true;
self
}
}
impl Default for RequestIdLayer {
fn default() -> Self {
Self::new()
}
}
impl<S> Layer<S> for RequestIdLayer {
type Service = RequestIdService<S>;
fn layer(&self, inner: S) -> Self::Service {
RequestIdService {
inner,
header_name: self.header_name.clone(),
generate: self.generate,
}
}
}
#[derive(Clone, Debug)]
pub struct RequestIdService<S> {
inner: S,
header_name: HeaderName,
generate: bool,
}
impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for RequestIdService<S>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>> + Clone + Send + 'static,
S::Future: Send + 'static,
S::Error: Send + 'static,
ReqBody: Send + 'static,
ResBody: Body<Data = Bytes> + Send + 'static,
ResBody::Error: Into<BoxError>,
{
type Response = Response<ResponseBody>;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, S::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
let header_name = self.header_name.clone();
let generate = self.generate;
Box::pin(async move {
let id = resolve_id(&req, &header_name, generate);
if let Some(ref id) = id {
req.extensions_mut().insert(RequestId(id.clone()));
}
let response = inner.call(req).await?;
Ok(apply_id(response, id, &header_name).await)
})
}
}
fn resolve_id<B>(req: &Request<B>, header_name: &HeaderName, generate: bool) -> Option<String> {
if let Some(id) = header_id(req.headers(), header_name) {
return Some(id);
}
if let Some(RequestId(id)) = req.extensions().get::<RequestId>()
&& !id.is_empty()
{
return Some(id.clone());
}
generate.then(generated_id).flatten()
}
fn header_id(headers: &HeaderMap, header_name: &HeaderName) -> Option<String> {
headers
.get(header_name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn generated_id() -> Option<String> {
#[cfg(feature = "uuid")]
{
Some(uuid::Uuid::new_v4().to_string())
}
#[cfg(not(feature = "uuid"))]
{
None
}
}
async fn apply_id<B>(
mut response: Response<B>,
id: Option<String>,
header_name: &HeaderName,
) -> Response<ResponseBody>
where
B: Body<Data = Bytes> + Send + 'static,
B::Error: Into<BoxError>,
{
let Some(id) = id else {
return response.map(box_inner);
};
if let Ok(value) = HeaderValue::from_str(&id) {
response.headers_mut().insert(header_name.clone(), value);
}
let status = response.status();
let is_error = status.is_client_error() || status.is_server_error();
if !is_error || !is_json_api(response.headers()) {
return response.map(box_inner);
}
let (parts, body) = response.into_parts();
let buffered = body
.collect()
.await
.map(|collected| collected.to_bytes())
.unwrap_or_default();
let stamped = stamp_error_ids_in_bytes(&buffered, &id).into_owned();
Response::from_parts(parts, box_bytes(Bytes::from(stamped)))
}
fn is_json_api(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|content_type| content_type.starts_with(JSON_API_MEDIA_TYPE))
}
fn box_bytes(bytes: Bytes) -> ResponseBody {
Full::new(bytes)
.map_err(|never| match never {})
.boxed_unsync()
}
fn box_inner<B>(body: B) -> ResponseBody
where
B: Body<Data = Bytes> + Send + 'static,
B::Error: Into<BoxError>,
{
body.map_err(Into::into).boxed_unsync()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::JsonApiError;
use axum::Router;
use axum::body::Body as AxumBody;
use axum::routing::get;
use http::StatusCode;
use serde_json::Value;
use tower::ServiceExt;
fn app() -> Router {
async fn ok() -> &'static str {
"ok-body"
}
async fn boom() -> JsonApiError {
JsonApiError::not_found("missing")
}
Router::new()
.route("/ok", get(ok))
.route("/boom", get(boom))
.layer(RequestIdLayer::new())
}
#[test]
fn error_response_gets_id_in_header_and_error_document() {
pollster::block_on(async {
let request = Request::builder()
.uri("/boom")
.header("x-request-id", "req-abc")
.body(AxumBody::empty())
.unwrap();
let response = app().oneshot(request).await.unwrap();
assert_eq!(
response
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok()),
Some("req-abc")
);
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(json["errors"][0]["id"], "req-abc");
});
}
#[test]
fn success_response_is_unchanged_but_carries_the_header() {
pollster::block_on(async {
let request = Request::builder()
.uri("/ok")
.header("x-request-id", "req-ok")
.body(AxumBody::empty())
.unwrap();
let response = app().oneshot(request).await.unwrap();
assert_eq!(
response
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok()),
Some("req-ok")
);
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(&bytes[..], b"ok-body");
});
}
#[test]
fn no_id_source_with_generation_off_is_a_noop() {
pollster::block_on(async {
let request = Request::builder()
.uri("/boom")
.body(AxumBody::empty())
.unwrap();
let response = app().oneshot(request).await.unwrap();
assert!(response.headers().get("x-request-id").is_none());
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert!(json["errors"][0].get("id").is_none());
});
}
#[test]
fn custom_header_name_is_read_and_echoed() {
pollster::block_on(async {
let router = Router::new()
.route(
"/boom",
get(|| async { JsonApiError::not_found("missing") }),
)
.layer(
RequestIdLayer::new().header_name(HeaderName::from_static("x-correlation-id")),
);
let request = Request::builder()
.uri("/boom")
.header("x-correlation-id", "corr-1")
.body(AxumBody::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert_eq!(
response
.headers()
.get("x-correlation-id")
.and_then(|v| v.to_str().ok()),
Some("corr-1")
);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["errors"][0]["id"], "corr-1");
});
}
#[test]
fn request_id_extractor_reads_the_resolved_id() {
pollster::block_on(async {
async fn echo(RequestId(id): RequestId) -> String {
id
}
let router = Router::new()
.route("/whoami", get(echo))
.layer(RequestIdLayer::new());
let request = Request::builder()
.uri("/whoami")
.header("x-request-id", "req-xyz")
.body(AxumBody::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], b"req-xyz");
});
}
#[cfg(feature = "uuid")]
#[test]
fn generation_stamps_a_uuid_when_no_upstream_id() {
pollster::block_on(async {
let router = Router::new()
.route(
"/boom",
get(|| async { JsonApiError::not_found("missing") }),
)
.layer(RequestIdLayer::new().generate());
let request = Request::builder()
.uri("/boom")
.body(AxumBody::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let header_id = response
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
assert!(header_id.is_some());
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["errors"][0]["id"], header_id.unwrap());
});
}
}