Skip to main content

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}