camel_api/http_error_mapping.rs
1//! Route-level mapping from business error payloads to HTTP statuses.
2//!
3//! [`HttpErrorMapping`] is a route-level field, deliberately separate from
4//! [`crate::error_handler::ErrorHandlerConfig`]: the mapping decides the
5//! reply status of a propagated `CamelError::Business`, while the error
6//! handler owns retry/dead-letter policy. A route may carry both.
7//!
8//! Status resolution precedence (see [`business_status`]):
9//!
10//! 1. explicit `codes` entry (exact, case-sensitive match on the code),
11//! 2. explicit `kinds` entry (exact, case-sensitive match on the kind),
12//! 3. the built-in default kind map ([`default_kind_status`]),
13//! 4. `500`.
14//!
15//! Values are restricted to the client/server error domain (`400..=599`).
16//! Load-time validation ([`validate_status`], [`validate_key`], and
17//! [`HttpErrorMapping::validate`]) rejects blank keys and out-of-domain
18//! values. Programmatic construction can bypass that validation, so
19//! resolution also defends: an out-of-domain entry is treated as absent and
20//! the remaining precedence rules apply. A business error can never resolve
21//! to a 2xx status.
22
23use std::collections::BTreeMap;
24
25use thiserror::Error;
26
27/// Lowest status an error mapping entry may produce (client error).
28pub const MIN_MAPPED_STATUS: u16 = 400;
29/// Highest status an error mapping entry may produce (server error).
30pub const MAX_MAPPED_STATUS: u16 = 599;
31/// Status used when no mapping entry and no built-in default matches.
32pub const DEFAULT_BUSINESS_STATUS: u16 = 500;
33
34/// Built-in default kind map for business errors.
35///
36/// Kebab-case kinds match the error kind strings exposed by
37/// `CamelError::classify()` and the `on_exceptions` matchers.
38const DEFAULT_KIND_STATUSES: [(&str, u16); 5] = [
39 ("invalid-argument", 400),
40 ("conflict", 409),
41 ("not-found", 404),
42 ("forbidden", 403),
43 ("failed", 500),
44];
45
46/// Which table of an [`HttpErrorMapping`] a key belongs to.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48#[non_exhaustive]
49pub enum MappingTable {
50 /// Exact business error code to status (`codes` table).
51 Codes,
52 /// Business error kind to status (`kinds` table).
53 Kinds,
54}
55
56impl MappingTable {
57 /// Table name as it appears in mapping documents.
58 #[must_use]
59 pub const fn name(self) -> &'static str {
60 match self {
61 Self::Codes => "codes",
62 Self::Kinds => "kinds",
63 }
64 }
65}
66
67/// Typed load-time validation failure for an [`HttpErrorMapping`].
68#[derive(Debug, Clone, PartialEq, Eq, Error)]
69#[non_exhaustive]
70pub enum HttpErrorMappingError {
71 /// A table key is empty or whitespace-only.
72 #[error("blank `{table}` key in HTTP error mapping")]
73 BlankKey {
74 /// Name of the offending table (`codes` or `kinds`).
75 table: &'static str,
76 },
77 /// A table value falls outside the client/server error domain.
78 #[error("HTTP error mapping `{table}` value {status} is outside 400-599")]
79 StatusOutOfDomain {
80 /// Name of the offending table (`codes` or `kinds`).
81 table: &'static str,
82 /// Rejected status value.
83 status: u16,
84 },
85}
86
87/// Returns `true` when `status` is a client or server error status
88/// (`400..=599`), the only domain a business error may map to.
89#[must_use]
90pub const fn is_mapped_status(status: u16) -> bool {
91 status >= MIN_MAPPED_STATUS && status <= MAX_MAPPED_STATUS
92}
93
94/// Reject a blank table key.
95///
96/// # Errors
97///
98/// Returns [`HttpErrorMappingError::BlankKey`] when `key` is empty or
99/// whitespace-only.
100pub fn validate_key(table: MappingTable, key: &str) -> Result<(), HttpErrorMappingError> {
101 if key.trim().is_empty() {
102 return Err(HttpErrorMappingError::BlankKey {
103 table: table.name(),
104 });
105 }
106 Ok(())
107}
108
109/// Reject a status outside the client/server error domain.
110///
111/// # Errors
112///
113/// Returns [`HttpErrorMappingError::StatusOutOfDomain`] for any value below
114/// 400 or above 599 (including informational, success, and redirect
115/// statuses such as 101, 200, and 304).
116pub fn validate_status(table: MappingTable, status: u16) -> Result<(), HttpErrorMappingError> {
117 if is_mapped_status(status) {
118 Ok(())
119 } else {
120 Err(HttpErrorMappingError::StatusOutOfDomain {
121 table: table.name(),
122 status,
123 })
124 }
125}
126
127/// Built-in default status for a business error kind, if the kind is mapped.
128///
129/// The default map is kebab-case and matches the existing error kind strings:
130/// `invalid-argument` 400, `conflict` 409, `not-found` 404, `forbidden` 403,
131/// `failed` 500.
132#[must_use]
133pub fn default_kind_status(kind: &str) -> Option<u16> {
134 DEFAULT_KIND_STATUSES
135 .iter()
136 .find_map(|(name, status)| (*name == kind).then_some(*status))
137}
138
139/// Resolve the reply status for a business error.
140///
141/// Resolution order: explicit code map, explicit kind map, built-in default
142/// kind map, [`DEFAULT_BUSINESS_STATUS`]. Out-of-domain explicit entries are
143/// treated as absent. `None` applies the built-in default map and the 500
144/// closure, so a route without a mapping still never yields 2xx.
145#[must_use]
146pub fn business_status(mapping: Option<&HttpErrorMapping>, code: &str, kind: &str) -> u16 {
147 mapping
148 .and_then(|mapping| mapping.explicit_status(code, kind))
149 .or_else(|| default_kind_status(kind))
150 .unwrap_or(DEFAULT_BUSINESS_STATUS)
151}
152
153/// Route-level business error to HTTP status mapping.
154///
155/// `codes` maps an exact business error code to a status; `kinds` maps an
156/// exact business error kind to a status. Both tables are case-sensitive and
157/// may be empty.
158///
159/// The mapping is a separate route-level field and never lives on
160/// [`crate::error_handler::ErrorHandlerConfig`]. It carries no `message` or
161/// `data`: the reply body for a business error is exactly
162/// `{"error": <code>, "kind": <kind>}`, because runtime exchange data is
163/// untrusted and no mapping authorizes its disclosure.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct HttpErrorMapping {
166 /// Exact business error code to HTTP status.
167 pub codes: BTreeMap<String, u16>,
168 /// Exact business error kind to HTTP status.
169 pub kinds: BTreeMap<String, u16>,
170}
171
172impl HttpErrorMapping {
173 /// Create an empty mapping (built-in defaults only).
174 #[must_use]
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 /// Add a code entry, returning `self` for builder-style construction.
180 #[must_use]
181 pub fn with_code(mut self, code: impl Into<String>, status: u16) -> Self {
182 self.codes.insert(code.into(), status);
183 self
184 }
185
186 /// Add a kind entry, returning `self` for builder-style construction.
187 #[must_use]
188 pub fn with_kind(mut self, kind: impl Into<String>, status: u16) -> Self {
189 self.kinds.insert(kind.into(), status);
190 self
191 }
192
193 /// Returns `true` when both tables are empty.
194 #[must_use]
195 pub fn is_empty(&self) -> bool {
196 self.codes.is_empty() && self.kinds.is_empty()
197 }
198
199 /// Validate every entry: non-blank keys and statuses in `400..=599`.
200 ///
201 /// # Errors
202 ///
203 /// Returns the first [`HttpErrorMappingError`] found, checking the
204 /// `codes` table before the `kinds` table.
205 pub fn validate(&self) -> Result<(), HttpErrorMappingError> {
206 for (code, status) in &self.codes {
207 validate_key(MappingTable::Codes, code)?;
208 validate_status(MappingTable::Codes, *status)?;
209 }
210 for (kind, status) in &self.kinds {
211 validate_key(MappingTable::Kinds, kind)?;
212 validate_status(MappingTable::Kinds, *status)?;
213 }
214 Ok(())
215 }
216
217 /// Explicit status for a business error, or `None` when no explicit
218 /// entry applies.
219 ///
220 /// The code table wins over the kind table. An out-of-domain entry is
221 /// treated as absent, so resolution continues with the remaining
222 /// precedence rules.
223 #[must_use]
224 pub fn explicit_status(&self, code: &str, kind: &str) -> Option<u16> {
225 self.codes
226 .get(code)
227 .copied()
228 .filter(|status| is_mapped_status(*status))
229 .or_else(|| {
230 self.kinds
231 .get(kind)
232 .copied()
233 .filter(|status| is_mapped_status(*status))
234 })
235 }
236}