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: String,
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(),
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 internal(request_id: impl Into<String>) -> Self {
64 Self::new(
65 StatusCode::INTERNAL_SERVER_ERROR,
66 "internal_error",
67 "Internal server error",
68 "The request could not be completed.",
69 request_id,
70 )
71 }
72}
73
74impl IntoResponse for ApiFailure {
75 fn into_response(self) -> Response {
76 problem_response(self)
77 }
78}
79
80pub fn problem_response(failure: ApiFailure) -> Response {
81 let problem = ProblemDetails {
82 type_uri: format!("https://minco.dev/problems/{}", failure.code),
83 title: failure.title,
84 status: failure.status.as_u16(),
85 detail: failure.detail,
86 code: failure.code,
87 request_id: failure.request_id.clone(),
88 errors: failure.errors,
89 };
90 let mut response = (failure.status, Json(problem)).into_response();
91 response.headers_mut().insert(
92 http::header::CONTENT_TYPE,
93 HeaderValue::from_static("application/problem+json"),
94 );
95 if let Ok(value) = HeaderValue::from_str(&failure.request_id) {
96 response
97 .headers_mut()
98 .insert(REQUEST_ID_HEADER.clone(), value);
99 }
100 response
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 #[test]
107 fn problem_type_is_stable_and_machine_readable() {
108 let response = ApiFailure::validation("bad input", "request-1").into_response();
109 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
110 assert_eq!(
111 response.headers()[http::header::CONTENT_TYPE],
112 "application/problem+json"
113 );
114 assert_eq!(response.headers()[&REQUEST_ID_HEADER], "request-1");
115 }
116}