Skip to main content

minco_http/
error.rs

1use axum::{
2    Json,
3    response::{IntoResponse, Response},
4};
5use http::{HeaderValue, StatusCode};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9use crate::{REQUEST_ID_HEADER, safe_request_id};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct ProblemDetails {
14    #[serde(rename = "type")]
15    pub type_uri: String,
16    pub title: String,
17    pub status: u16,
18    pub detail: String,
19    pub code: String,
20    pub request_id: String,
21    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
22    pub errors: BTreeMap<String, Vec<String>>,
23}
24
25#[derive(Debug, Clone)]
26pub struct ApiFailure {
27    pub status: StatusCode,
28    pub code: Box<str>,
29    pub title: String,
30    pub detail: String,
31    pub request_id: String,
32    pub errors: BTreeMap<String, Vec<String>>,
33}
34
35impl ApiFailure {
36    pub fn new(
37        status: StatusCode,
38        code: impl Into<String>,
39        title: impl Into<String>,
40        detail: impl Into<String>,
41        request_id: impl Into<String>,
42    ) -> Self {
43        Self {
44            status,
45            code: code.into().into_boxed_str(),
46            title: title.into(),
47            detail: detail.into(),
48            request_id: request_id.into(),
49            errors: BTreeMap::new(),
50        }
51    }
52
53    pub fn validation(detail: impl Into<String>, request_id: impl Into<String>) -> Self {
54        Self::new(
55            StatusCode::UNPROCESSABLE_ENTITY,
56            "validation_failed",
57            "Validation failed",
58            detail,
59            request_id,
60        )
61    }
62
63    pub fn precondition_required(request_id: impl Into<String>) -> Self {
64        Self::new(
65            StatusCode::PRECONDITION_REQUIRED,
66            "precondition_required",
67            "Precondition required",
68            "This operation requires an If-Match header containing the current entity tag.",
69            request_id,
70        )
71    }
72
73    pub fn precondition_failed(request_id: impl Into<String>) -> Self {
74        Self::new(
75            StatusCode::PRECONDITION_FAILED,
76            "precondition_failed",
77            "Precondition failed",
78            "The resource changed after it was read. Fetch the current representation and retry.",
79            request_id,
80        )
81    }
82
83    pub fn invalid_if_match(request_id: impl Into<String>) -> Self {
84        Self::new(
85            StatusCode::BAD_REQUEST,
86            "invalid_if_match",
87            "Invalid If-Match header",
88            "If-Match must contain exactly one strong entity tag returned by this API.",
89            request_id,
90        )
91    }
92
93    pub fn internal(request_id: impl Into<String>) -> Self {
94        Self::new(
95            StatusCode::INTERNAL_SERVER_ERROR,
96            "internal_error",
97            "Internal server error",
98            "The request could not be completed.",
99            request_id,
100        )
101    }
102}
103
104impl IntoResponse for ApiFailure {
105    fn into_response(self) -> Response {
106        problem_response(self)
107    }
108}
109
110pub fn problem_response(failure: ApiFailure) -> Response {
111    let request_id = safe_request_id(Some(&failure.request_id));
112    let bearer_challenge =
113        failure.status == StatusCode::UNAUTHORIZED && failure.code.as_ref() == "unauthenticated";
114    let problem = ProblemDetails {
115        type_uri: format!("https://minco.dev/problems/{}", failure.code),
116        title: failure.title,
117        status: failure.status.as_u16(),
118        detail: failure.detail,
119        code: failure.code.into(),
120        request_id: request_id.clone(),
121        errors: failure.errors,
122    };
123    let mut response = (failure.status, Json(problem)).into_response();
124    response.headers_mut().insert(
125        http::header::CONTENT_TYPE,
126        HeaderValue::from_static("application/problem+json"),
127    );
128    let value = HeaderValue::from_str(&request_id).expect("safe request IDs are valid headers");
129    response
130        .headers_mut()
131        .insert(REQUEST_ID_HEADER.clone(), value);
132    if bearer_challenge {
133        response.headers_mut().insert(
134            http::header::WWW_AUTHENTICATE,
135            HeaderValue::from_static("Bearer"),
136        );
137    }
138    response
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    #[test]
145    fn problem_type_is_stable_and_machine_readable() {
146        let response = ApiFailure::validation("bad input", "request-1").into_response();
147        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
148        assert_eq!(
149            response.headers()[http::header::CONTENT_TYPE],
150            "application/problem+json"
151        );
152        assert_eq!(response.headers()[&REQUEST_ID_HEADER], "request-1");
153    }
154}