use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use http::{HeaderMap, Request, Response, StatusCode, header};
use http_body::Body;
use http_body_util::{BodyExt, Full, combinators::UnsyncBoxBody};
use tower::{Layer, Service};
use jsonapi_http::{JSON_API_MEDIA_TYPE, error_response_for_status};
use crate::error::JsonApiError;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
type ResponseBody = UnsyncBoxBody<Bytes, BoxError>;
pub async fn not_found() -> JsonApiError {
JsonApiError::from_status(
StatusCode::NOT_FOUND,
Some("no route matched the request URI".to_string()),
)
}
#[derive(Clone, Debug, Default)]
pub struct NormalizeErrorsLayer;
impl NormalizeErrorsLayer {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl<S> Layer<S> for NormalizeErrorsLayer {
type Service = NormalizeErrorsService<S>;
fn layer(&self, inner: S) -> Self::Service {
NormalizeErrorsService { inner }
}
}
#[derive(Clone, Debug)]
pub struct NormalizeErrorsService<S> {
inner: S,
}
impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for NormalizeErrorsService<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, req: Request<ReqBody>) -> Self::Future {
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
Box::pin(async move {
let response = inner.call(req).await?;
Ok(normalize(response).await)
})
}
}
async fn normalize<B>(response: Response<B>) -> Response<ResponseBody>
where
B: Body<Data = Bytes> + Send + 'static,
B::Error: Into<BoxError>,
{
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 detail = collect_detail(response.into_body()).await;
error_response_for_status(status, detail).map(box_bytes)
}
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))
}
async fn collect_detail<B>(body: B) -> Option<String>
where
B: Body<Data = Bytes> + Send + 'static,
B::Error: Into<BoxError>,
{
let bytes = body.collect().await.ok()?.to_bytes();
let text = std::str::from_utf8(&bytes).ok()?.trim();
if text.is_empty() {
None
} else {
Some(text.to_string())
}
}
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 axum::Router;
use axum::body::Body as AxumBody;
use axum::routing::get;
use serde_json::Value;
use tower::ServiceExt;
fn app() -> Router {
async fn list() -> &'static str {
"ok"
}
async fn boom() -> (StatusCode, &'static str) {
(StatusCode::INTERNAL_SERVER_ERROR, "kaboom")
}
async fn already_json_api() -> JsonApiError {
JsonApiError::from_status(StatusCode::CONFLICT, Some("dup".into()))
}
Router::new()
.route("/articles", get(list))
.route("/boom", get(boom))
.route("/conflict", get(already_json_api))
.fallback(not_found)
.layer(NormalizeErrorsLayer::new())
}
fn get_request(uri: &str) -> Request<AxumBody> {
Request::builder().uri(uri).body(AxumBody::empty()).unwrap()
}
fn post_request(uri: &str) -> Request<AxumBody> {
Request::builder()
.method("POST")
.uri(uri)
.body(AxumBody::empty())
.unwrap()
}
async fn read(response: Response<AxumBody>) -> (StatusCode, String, Value) {
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, content_type, value)
}
#[test]
fn unmatched_route_yields_404_json_api_document() {
pollster::block_on(async {
let response = app().oneshot(get_request("/nope")).await.unwrap();
let (status, content_type, json) = read(response).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(content_type, JSON_API_MEDIA_TYPE);
assert_eq!(json["errors"][0]["status"], "404");
});
}
#[test]
fn wrong_method_yields_405_json_api_document() {
pollster::block_on(async {
let response = app().oneshot(post_request("/articles")).await.unwrap();
let (status, content_type, json) = read(response).await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(content_type, JSON_API_MEDIA_TYPE);
assert_eq!(json["errors"][0]["status"], "405");
});
}
#[test]
fn plain_text_500_is_normalized_and_folds_body_into_detail() {
pollster::block_on(async {
let response = app().oneshot(get_request("/boom")).await.unwrap();
let (status, content_type, json) = read(response).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(content_type, JSON_API_MEDIA_TYPE);
assert_eq!(json["errors"][0]["status"], "500");
assert_eq!(json["errors"][0]["detail"], "kaboom");
});
}
#[test]
fn existing_json_api_error_passes_through_unchanged() {
pollster::block_on(async {
let response = app().oneshot(get_request("/conflict")).await.unwrap();
let (status, content_type, json) = read(response).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(content_type, JSON_API_MEDIA_TYPE);
assert_eq!(json["errors"].as_array().unwrap().len(), 1);
assert_eq!(json["errors"][0]["status"], "409");
assert_eq!(json["errors"][0]["detail"], "dup");
});
}
#[test]
fn success_response_passes_through_untouched() {
pollster::block_on(async {
let response = app().oneshot(get_request("/articles")).await.unwrap();
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");
});
}
}