Skip to main content

lino_rest_api/
problem.rs

1//! RFC 9457 problem details, expressed in Links Notation (specification section 5).
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use lino_objects_codec::LinoValue;
7
8use crate::value::{int, object, string};
9
10/// Base URI every registered problem type is resolved against.
11pub const PROBLEM_TYPE_BASE: &str = "https://link-foundation.github.io/lino-rest-api/errors/";
12
13/// Reason phrase of a status code, falling back to a generic class phrase.
14pub fn reason_phrase(status: u16) -> &'static str {
15    match status {
16        400 => "Bad Request",
17        401 => "Unauthorized",
18        403 => "Forbidden",
19        404 => "Not Found",
20        405 => "Method Not Allowed",
21        406 => "Not Acceptable",
22        409 => "Conflict",
23        410 => "Gone",
24        412 => "Precondition Failed",
25        413 => "Content Too Large",
26        415 => "Unsupported Media Type",
27        422 => "Unprocessable Content",
28        428 => "Precondition Required",
29        429 => "Too Many Requests",
30        500 => "Internal Server Error",
31        501 => "Not Implemented",
32        502 => "Bad Gateway",
33        503 => "Service Unavailable",
34        504 => "Gateway Timeout",
35        status if status >= 500 => "Server Error",
36        _ => "Request Error",
37    }
38}
39
40/// Kebab-case slug used as the last segment of a problem type URI.
41///
42/// # Examples
43///
44/// ```
45/// use lino_rest_api::problem::problem_slug;
46///
47/// assert_eq!(problem_slug("Precondition Required"), "precondition-required");
48/// ```
49pub fn problem_slug(title: &str) -> String {
50    let mut slug = String::new();
51    let mut pending_dash = false;
52    for character in title.to_lowercase().chars() {
53        if character.is_ascii_alphanumeric() {
54            if pending_dash && !slug.is_empty() {
55                slug.push('-');
56            }
57            pending_dash = false;
58            slug.push(character);
59        } else {
60            pending_dash = true;
61        }
62    }
63    slug
64}
65
66/// An HTTP error that carries problem details.
67///
68/// Returning one of these from a handler produces a conforming error response;
69/// the client returns the same shape when a server answers with 4xx or 5xx.
70#[derive(Debug, Clone, PartialEq)]
71pub struct LinoHttpError {
72    /// HTTP status code.
73    pub status: u16,
74    /// Short, type-wide summary.
75    pub title: String,
76    /// Human readable explanation of this occurrence.
77    pub detail: Option<String>,
78    /// Problem type URI.
79    pub type_uri: String,
80    /// URI of this occurrence.
81    pub instance: Option<String>,
82    /// Response headers to send with the error.
83    pub headers: BTreeMap<String, String>,
84    /// Extra problem members.
85    pub extensions: Vec<(String, LinoValue)>,
86}
87
88impl LinoHttpError {
89    /// An error with a status and an optional detail, titled after the status.
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// use lino_rest_api::problem::LinoHttpError;
95    ///
96    /// let error = LinoHttpError::new(404, Some("No task 7"));
97    /// assert_eq!(error.title, "Not Found");
98    /// assert!(error.type_uri.ends_with("not-found"));
99    /// ```
100    pub fn new(status: u16, detail: Option<&str>) -> Self {
101        let title = reason_phrase(status).to_string();
102        Self {
103            status,
104            type_uri: format!("{PROBLEM_TYPE_BASE}{}", problem_slug(&title)),
105            title,
106            detail: detail.map(str::to_string),
107            instance: None,
108            headers: BTreeMap::new(),
109            extensions: Vec::new(),
110        }
111    }
112
113    /// The same error with another title, which also retitles the type URI.
114    pub fn with_title<S: Into<String>>(mut self, title: S) -> Self {
115        self.title = title.into();
116        self.type_uri = format!("{PROBLEM_TYPE_BASE}{}", problem_slug(&self.title));
117        self
118    }
119
120    /// The same error with an explicit problem type URI.
121    pub fn with_type<S: Into<String>>(mut self, type_uri: S) -> Self {
122        self.type_uri = type_uri.into();
123        self
124    }
125
126    /// The same error with the URI of this occurrence.
127    pub fn with_instance<S: Into<String>>(mut self, instance: S) -> Self {
128        self.instance = Some(instance.into());
129        self
130    }
131
132    /// The same error with one more response header.
133    pub fn with_header<N: Into<String>, V: Into<String>>(mut self, name: N, value: V) -> Self {
134        self.headers.insert(name.into(), value.into());
135        self
136    }
137
138    /// The same error with one more problem member.
139    pub fn with_extension<S: Into<String>>(mut self, key: S, value: LinoValue) -> Self {
140        self.extensions.push((key.into(), value));
141        self
142    }
143
144    /// Human readable message: the detail when present, the title otherwise.
145    pub fn message(&self) -> &str {
146        self.detail.as_deref().unwrap_or(&self.title)
147    }
148
149    /// Render the error as the problem details object of the specification.
150    pub fn to_problem(&self, instance: Option<&str>) -> LinoValue {
151        let mut problem = object([
152            ("type", string(self.type_uri.clone())),
153            ("title", string(self.title.clone())),
154            ("status", int(i64::from(self.status))),
155        ]);
156        if let LinoValue::Object(members) = &mut problem {
157            if let Some(detail) = &self.detail {
158                members.push(("detail".to_string(), string(detail.clone())));
159            }
160            let resolved = self
161                .instance
162                .as_deref()
163                .filter(|text| !text.is_empty())
164                .or(instance)
165                .filter(|text| !text.is_empty());
166            if let Some(resolved) = resolved {
167                members.push(("instance".to_string(), string(resolved)));
168            }
169            for (key, value) in &self.extensions {
170                members.push((key.clone(), value.clone()));
171            }
172        }
173        problem
174    }
175}
176
177impl fmt::Display for LinoHttpError {
178    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
179        formatter.write_str(self.message())
180    }
181}
182
183impl std::error::Error for LinoHttpError {}
184
185/// Build problem details for a status code without constructing an error first.
186pub fn problem_details(status: u16, detail: Option<&str>, instance: Option<&str>) -> LinoValue {
187    LinoHttpError::new(status, detail).to_problem(instance)
188}
189
190/// A 422 carrying field-level validation failures.
191///
192/// Each failure is an object with a `field` and a `message`, exactly as the
193/// JavaScript and Python packages send it.
194pub fn validation_error(errors: Vec<(String, String)>, detail: &str) -> LinoHttpError {
195    let items = errors
196        .into_iter()
197        .map(|(field, message)| object([("field", string(field)), ("message", string(message))]))
198        .collect::<Vec<_>>();
199    LinoHttpError::new(422, Some(detail))
200        .with_title("Unprocessable Content")
201        .with_type(format!("{PROBLEM_TYPE_BASE}validation-failed"))
202        .with_extension("errors", LinoValue::Array(items))
203}