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;
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 problem = ProblemDetails {
112        type_uri: format!("https://minco.dev/problems/{}", failure.code),
113        title: failure.title,
114        status: failure.status.as_u16(),
115        detail: failure.detail,
116        code: failure.code.into(),
117        request_id: failure.request_id.clone(),
118        errors: failure.errors,
119    };
120    let mut response = (failure.status, Json(problem)).into_response();
121    response.headers_mut().insert(
122        http::header::CONTENT_TYPE,
123        HeaderValue::from_static("application/problem+json"),
124    );
125    if let Ok(value) = HeaderValue::from_str(&failure.request_id) {
126        response
127            .headers_mut()
128            .insert(REQUEST_ID_HEADER.clone(), value);
129    }
130    response
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    #[test]
137    fn problem_type_is_stable_and_machine_readable() {
138        let response = ApiFailure::validation("bad input", "request-1").into_response();
139        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
140        assert_eq!(
141            response.headers()[http::header::CONTENT_TYPE],
142            "application/problem+json"
143        );
144        assert_eq!(response.headers()[&REQUEST_ID_HEADER], "request-1");
145    }
146}