use std::convert::Infallible;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::http::{HeaderMap, Request, Response, StatusCode, header};
use tower::{Layer, Service};
use crate::api::{Problem, ProblemKind};
pub type Mapper =
Arc<dyn Fn(StatusCode, &HeaderMap) -> Option<Response<axum::body::Body>> + Send + Sync>;
#[derive(Clone)]
pub struct ErrorMapping {
mapper: Option<Mapper>,
redact: bool,
}
impl Default for ErrorMapping {
fn default() -> Self {
Self::new()
}
}
impl ErrorMapping {
#[must_use]
pub fn new() -> Self {
ErrorMapping {
mapper: None,
redact: !cfg!(debug_assertions),
}
}
#[must_use]
pub fn redact_errors(mut self, redact: bool) -> Self {
self.redact = redact;
self
}
#[must_use]
pub fn with<F>(mut self, mapper: F) -> Self
where
F: Fn(StatusCode, &HeaderMap) -> Option<Response<axum::body::Body>> + Send + Sync + 'static,
{
self.mapper = Some(Arc::new(mapper));
self
}
#[must_use]
pub fn redacts(&self) -> bool {
self.redact
}
fn map(
&self,
request_headers: &HeaderMap,
response: Response<axum::body::Body>,
) -> Response<axum::body::Body> {
let status = response.status();
if !(status.is_client_error() || status.is_server_error()) {
return response;
}
if let Some(mapper) = &self.mapper
&& let Some(replacement) = mapper(status, request_headers)
{
return carry_headers(response, replacement);
}
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok());
match content_type {
Some(ct) if ct.starts_with("text/plain") && LAYER_AUTHORED.contains(&status) => {
rebuild(response, problem_for(status))
}
Some(ct) if self.redact && status.is_server_error() && ct.starts_with("text/plain") => {
rebuild(response, problem_for(status))
}
Some(_) => response,
None => rebuild(response, problem_for(status)),
}
}
}
impl std::fmt::Debug for ErrorMapping {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ErrorMapping")
.field("custom_mapper", &self.mapper.is_some())
.field("redact", &self.redact)
.finish()
}
}
const LAYER_AUTHORED: &[StatusCode] = &[
StatusCode::METHOD_NOT_ALLOWED,
StatusCode::REQUEST_TIMEOUT,
StatusCode::PAYLOAD_TOO_LARGE,
];
fn carry_headers(
original: Response<axum::body::Body>,
mut replacement: Response<axum::body::Body>,
) -> Response<axum::body::Body> {
let (parts, _body) = original.into_parts();
for (name, value) in &parts.headers {
if name == header::CONTENT_TYPE
|| name == header::CONTENT_LENGTH
|| replacement.headers().contains_key(name)
{
continue;
}
replacement
.headers_mut()
.append(name.clone(), value.clone());
}
replacement
}
fn problem_for(status: StatusCode) -> Problem {
match ProblemKind::for_status(status) {
Some(kind) => Problem::of(kind),
None => Problem::custom("about:blank", status),
}
}
fn rebuild(response: Response<axum::body::Body>, problem: Problem) -> Response<axum::body::Body> {
use axum::response::IntoResponse as _;
let (parts, _body) = response.into_parts();
let mut replacement = problem.into_response();
for (name, value) in &parts.headers {
if name == header::CONTENT_TYPE || name == header::CONTENT_LENGTH {
continue;
}
replacement
.headers_mut()
.append(name.clone(), value.clone());
}
*replacement.extensions_mut() = parts.extensions;
replacement
}
impl<S> Layer<S> for ErrorMapping {
type Service = ErrorMappingService<S>;
fn layer(&self, inner: S) -> Self::Service {
ErrorMappingService {
inner,
mapping: self.clone(),
}
}
}
#[derive(Clone, Debug)]
pub struct ErrorMappingService<S> {
inner: S,
mapping: ErrorMapping,
}
impl<S> Service<Request<axum::body::Body>> for ErrorMappingService<S>
where
S: Service<
Request<axum::body::Body>,
Response = Response<axum::body::Body>,
Error = Infallible,
> + Clone
+ Send
+ 'static,
S::Future: Send + 'static,
{
type Response = Response<axum::body::Body>;
type Error = Infallible;
type Future =
Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, request: Request<axum::body::Body>) -> Self::Future {
let request_headers = self
.mapping
.mapper
.is_some()
.then(|| request.headers().clone());
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
let mapping = self.mapping.clone();
Box::pin(async move {
let response = inner.call(request).await?;
let request_headers = request_headers.unwrap_or_default();
Ok(mapping.map(&request_headers, response))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::HeaderValue;
fn bare(status: StatusCode) -> Response<Body> {
Response::builder()
.status(status)
.body(Body::empty())
.expect("response")
}
fn typed(status: StatusCode, content_type: &str, body: &'static str) -> Response<Body> {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.body(Body::from(body))
.expect("response")
}
fn content_type_of(response: &Response<Body>) -> Option<&str> {
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
}
#[test]
fn a_success_is_never_touched() {
let response = ErrorMapping::new().map(&HeaderMap::new(), bare(StatusCode::NO_CONTENT));
assert_eq!(content_type_of(&response), None);
}
#[test]
fn a_bare_404_gets_a_problem_body() {
let response = ErrorMapping::new().map(&HeaderMap::new(), bare(StatusCode::NOT_FOUND));
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(content_type_of(&response), Some("application/problem+json"));
}
#[test]
fn a_status_with_no_distinguished_kind_still_gets_a_document() {
let response = ErrorMapping::new().map(&HeaderMap::new(), bare(StatusCode::BAD_GATEWAY));
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(content_type_of(&response), Some("application/problem+json"));
}
#[test]
fn a_body_the_application_chose_is_left_alone() {
let response = ErrorMapping::new().redact_errors(true).map(
&HeaderMap::new(),
typed(StatusCode::NOT_FOUND, "text/html", "<h1>gone</h1>"),
);
assert_eq!(content_type_of(&response), Some("text/html"));
}
#[test]
fn a_text_plain_5xx_is_redacted_when_redaction_is_on() {
let response = ErrorMapping::new().redact_errors(true).map(
&HeaderMap::new(),
typed(
StatusCode::INTERNAL_SERVER_ERROR,
"text/plain; charset=utf-8",
"postgres://user:hunter2@db/app: connection refused",
),
);
assert_eq!(content_type_of(&response), Some("application/problem+json"));
}
#[test]
fn a_text_plain_5xx_survives_when_redaction_is_off() {
let response = ErrorMapping::new().redact_errors(false).map(
&HeaderMap::new(),
typed(StatusCode::INTERNAL_SERVER_ERROR, "text/plain", "boom"),
);
assert_eq!(content_type_of(&response), Some("text/plain"));
}
#[test]
fn a_text_plain_4xx_is_not_redacted() {
let response = ErrorMapping::new().redact_errors(true).map(
&HeaderMap::new(),
typed(StatusCode::BAD_REQUEST, "text/plain", "missing `id`"),
);
assert_eq!(content_type_of(&response), Some("text/plain"));
}
#[test]
fn headers_the_client_acts_on_survive_the_rewrite() {
let original = Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.header(header::ALLOW, "GET, HEAD")
.body(Body::empty())
.expect("response");
let response = ErrorMapping::new().map(&HeaderMap::new(), original);
assert_eq!(
response.headers().get(header::ALLOW),
Some(&HeaderValue::from_static("GET, HEAD"))
);
assert_eq!(content_type_of(&response), Some("application/problem+json"));
}
#[test]
fn a_layer_authored_text_plain_error_is_replaced() {
let response = ErrorMapping::new().redact_errors(false).map(
&HeaderMap::new(),
typed(
StatusCode::PAYLOAD_TOO_LARGE,
"text/plain; charset=utf-8",
"length limit exceeded",
),
);
assert_eq!(content_type_of(&response), Some("application/problem+json"));
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[test]
fn a_mapper_reads_the_request_headers_not_the_response_ones() {
let mapping = ErrorMapping::new().with(|status, request_headers| {
request_headers.contains_key(header::ACCEPT).then(|| {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/html")
.body(Body::from("<h1>negotiated</h1>"))
.expect("response")
})
});
let mut request_headers = HeaderMap::new();
request_headers.insert(header::ACCEPT, HeaderValue::from_static("text/html"));
assert_eq!(
content_type_of(&mapping.map(&request_headers, bare(StatusCode::NOT_FOUND))),
Some("text/html")
);
assert_eq!(
content_type_of(&mapping.map(&HeaderMap::new(), bare(StatusCode::NOT_FOUND))),
Some("application/problem+json")
);
}
#[test]
fn a_replacement_inherits_the_headers_it_did_not_set() {
let mapping = ErrorMapping::new().with(|status, _headers| {
Some(
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/html")
.body(Body::from("<h1>gone</h1>"))
.expect("response"),
)
});
let original = Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.header(header::ALLOW, "GET, HEAD")
.body(Body::empty())
.expect("response");
let response = mapping.map(&HeaderMap::new(), original);
assert_eq!(
response.headers().get(header::ALLOW),
Some(&HeaderValue::from_static("GET, HEAD"))
);
assert_eq!(content_type_of(&response), Some("text/html"));
}
#[test]
fn a_custom_mapper_wins_and_can_also_decline() {
let mapping = ErrorMapping::new().with(|status, _headers| {
(status == StatusCode::NOT_FOUND).then(|| {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/html")
.body(Body::from("<h1>not here</h1>"))
.expect("response")
})
});
assert_eq!(
content_type_of(&mapping.map(&HeaderMap::new(), bare(StatusCode::NOT_FOUND))),
Some("text/html")
);
assert_eq!(
content_type_of(&mapping.map(&HeaderMap::new(), bare(StatusCode::REQUEST_TIMEOUT))),
Some("application/problem+json")
);
}
#[test]
fn redaction_defaults_to_off_in_a_debug_build_and_on_otherwise() {
assert_eq!(ErrorMapping::new().redacts(), !cfg!(debug_assertions));
}
}