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}