Skip to main content

ranvier_http/
bus_ext.rs

1//! # HTTP-Specific Bus Extensions
2//!
3//! Convenience methods for extracting HTTP-specific types (PathParams, QueryParams)
4//! from the Bus. These belong in `ranvier-http` (not `ranvier-core`) because they
5//! reference protocol-specific types, preserving Core's protocol-agnosticism.
6//!
7//! ## Design Rationale
8//!
9//! The Bus in `ranvier-core` is protocol-agnostic and uses type-indexed storage.
10//! PathParams and QueryParams are HTTP concepts defined in `ranvier-http`.
11//! Extension traits allow a clean API (`bus.path_param("id")`) without
12//! coupling the core framework to HTTP semantics.
13
14use std::str::FromStr;
15
16use ranvier_core::Bus;
17use serde::Serialize;
18
19use crate::ingress::{PathParams, QueryParams};
20
21/// HTTP-specific convenience methods for [`Bus`].
22///
23/// Import this trait to access path/query parameter extraction directly from the Bus.
24///
25/// # Example
26///
27/// ```rust,ignore
28/// use ranvier_http::BusHttpExt;
29/// use uuid::Uuid;
30///
31/// let id: Uuid = ranvier_core::try_outcome!(bus.path_param("id"), "path");
32/// let page: i64 = bus.query_param_or("page", 1);
33/// ```
34pub trait BusHttpExt {
35    /// Extract and parse a path parameter from the Bus.
36    ///
37    /// Looks up [`PathParams`] in the Bus, then parses the named parameter as `T`.
38    ///
39    /// Returns `Err` if:
40    /// - `PathParams` is not in the Bus
41    /// - The named key does not exist in PathParams
42    /// - The value cannot be parsed as `T`
43    ///
44    /// # Example
45    ///
46    /// ```rust,ignore
47    /// use ranvier_http::BusHttpExt;
48    /// use uuid::Uuid;
49    ///
50    /// // Inside a transition:
51    /// let id: Uuid = ranvier_core::try_outcome!(bus.path_param("id"), "path");
52    /// ```
53    fn path_param<T: FromStr>(&self, name: &str) -> Result<T, String>;
54
55    /// Extract and parse a query parameter from the Bus.
56    ///
57    /// Returns `None` if `QueryParams` is missing, the key is absent, or parsing fails.
58    ///
59    /// # Example
60    ///
61    /// ```rust,ignore
62    /// use ranvier_http::BusHttpExt;
63    ///
64    /// let page: Option<i64> = bus.query_param("page");
65    /// ```
66    fn query_param<T: FromStr>(&self, name: &str) -> Option<T>;
67
68    /// Extract and parse a query parameter, or return a default value.
69    ///
70    /// # Example
71    ///
72    /// ```rust,ignore
73    /// use ranvier_http::BusHttpExt;
74    ///
75    /// let page: i64 = bus.query_param_or("page", 1);
76    /// let per_page: i64 = bus.query_param_or("per_page", 20);
77    /// ```
78    fn query_param_or<T: FromStr>(&self, name: &str, default: T) -> T;
79}
80
81impl BusHttpExt for Bus {
82    fn path_param<T: FromStr>(&self, name: &str) -> Result<T, String> {
83        self.read::<PathParams>()
84            .and_then(|p| p.get_parsed::<T>(name))
85            .ok_or_else(|| format!("Missing or invalid path parameter: {name}"))
86    }
87
88    fn query_param<T: FromStr>(&self, name: &str) -> Option<T> {
89        self.read::<QueryParams>()
90            .and_then(|q| q.get_parsed::<T>(name))
91    }
92
93    fn query_param_or<T: FromStr>(&self, name: &str, default: T) -> T {
94        self.query_param(name).unwrap_or(default)
95    }
96}
97
98/// Create an `Outcome::Next` with a JSON-serialized value, or `Outcome::Fault` on error.
99///
100/// This is a convenience function for the common pattern of serializing a response
101/// to JSON and wrapping it in `Outcome::Next`. Placed in `ranvier-http` (not `ranvier-core`)
102/// because JSON serialization is a protocol-boundary concern.
103///
104/// # Example
105///
106/// ```rust,ignore
107/// use ranvier_http::json_outcome;
108/// use serde::Serialize;
109///
110/// #[derive(Serialize)]
111/// struct ApiResponse { status: String }
112///
113/// let response = ApiResponse { status: "ok".into() };
114/// let outcome = json_outcome(&response);
115/// // outcome == Outcome::Next(r#"{"status":"ok"}"#.to_string())
116/// ```
117pub fn json_outcome<T: Serialize>(value: &T) -> ranvier_core::Outcome<String, String> {
118    match serde_json::to_string(value) {
119        Ok(json) => ranvier_core::Outcome::Next(json),
120        Err(e) => ranvier_core::Outcome::Fault(format!("JSON serialization failed: {e}")),
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use ranvier_core::Bus;
128    use std::collections::HashMap;
129
130    #[test]
131    fn path_param_parses_uuid() {
132        let mut bus = Bus::new();
133        let mut values = HashMap::new();
134        values.insert("id".to_string(), "550e8400-e29b-41d4-a716-446655440000".to_string());
135        bus.insert(PathParams::new(values));
136
137        let id: uuid::Uuid = bus.path_param("id").unwrap();
138        assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
139    }
140
141    #[test]
142    fn path_param_parses_i64() {
143        let mut bus = Bus::new();
144        let mut values = HashMap::new();
145        values.insert("page".to_string(), "42".to_string());
146        bus.insert(PathParams::new(values));
147
148        let page: i64 = bus.path_param("page").unwrap();
149        assert_eq!(page, 42);
150    }
151
152    #[test]
153    fn path_param_missing_returns_err() {
154        let bus = Bus::new();
155        let result: Result<i64, String> = bus.path_param("id");
156        assert!(result.is_err());
157        assert!(result.unwrap_err().contains("Missing or invalid"));
158    }
159
160    #[test]
161    fn path_param_invalid_parse_returns_err() {
162        let mut bus = Bus::new();
163        let mut values = HashMap::new();
164        values.insert("id".to_string(), "not-a-uuid".to_string());
165        bus.insert(PathParams::new(values));
166
167        let result: Result<uuid::Uuid, String> = bus.path_param("id");
168        assert!(result.is_err());
169    }
170
171    #[test]
172    fn query_param_parses_value() {
173        let mut bus = Bus::new();
174        bus.insert(QueryParams::from_query("page=5&limit=20"));
175
176        let page: Option<i64> = bus.query_param("page");
177        assert_eq!(page, Some(5));
178
179        let limit: Option<i64> = bus.query_param("limit");
180        assert_eq!(limit, Some(20));
181    }
182
183    #[test]
184    fn query_param_missing_returns_none() {
185        let mut bus = Bus::new();
186        bus.insert(QueryParams::from_query("page=1"));
187
188        let missing: Option<i64> = bus.query_param("nonexistent");
189        assert!(missing.is_none());
190    }
191
192    #[test]
193    fn query_param_no_query_params_in_bus_returns_none() {
194        let bus = Bus::new();
195        let result: Option<i64> = bus.query_param("page");
196        assert!(result.is_none());
197    }
198
199    #[test]
200    fn query_param_or_returns_parsed_value() {
201        let mut bus = Bus::new();
202        bus.insert(QueryParams::from_query("page=3"));
203
204        let page: i64 = bus.query_param_or("page", 1);
205        assert_eq!(page, 3);
206    }
207
208    #[test]
209    fn query_param_or_returns_default_when_missing() {
210        let mut bus = Bus::new();
211        bus.insert(QueryParams::from_query(""));
212
213        let page: i64 = bus.query_param_or("page", 1);
214        assert_eq!(page, 1);
215
216        let per_page: i64 = bus.query_param_or("per_page", 20);
217        assert_eq!(per_page, 20);
218    }
219
220    #[test]
221    fn json_outcome_success() {
222        #[derive(Serialize)]
223        struct Resp {
224            status: String,
225        }
226        let resp = Resp {
227            status: "ok".into(),
228        };
229        let outcome = json_outcome(&resp);
230        assert!(outcome.is_next());
231        match outcome {
232            ranvier_core::Outcome::Next(json) => {
233                assert!(json.contains("\"status\""));
234                assert!(json.contains("\"ok\""));
235            }
236            _ => panic!("Expected Next"),
237        }
238    }
239
240    #[test]
241    fn json_outcome_with_vec() {
242        let items = vec![1, 2, 3];
243        let outcome = json_outcome(&items);
244        assert!(outcome.is_next());
245        match outcome {
246            ranvier_core::Outcome::Next(json) => {
247                assert_eq!(json, "[1,2,3]");
248            }
249            _ => panic!("Expected Next"),
250        }
251    }
252}