Skip to main content

spikard_http/lifecycle/
adapter.rs

1//! Shared utilities for lifecycle hook implementations across language bindings.
2//!
3//! This module provides common error messages, hook registration patterns, and
4//! serialization utilities to eliminate duplication across Python, Node.js,
5//! Ruby, and WASM bindings.
6
7use crate::lifecycle::LifecycleHook;
8use axum::body::Body;
9use axum::http::{Request, Response};
10use std::sync::Arc;
11
12/// Standard error message formatters for lifecycle hooks.
13/// These are used consistently across all language bindings.
14pub mod error {
15    use std::fmt::Display;
16
17    /// Format error when a hook invocation fails
18    pub fn call_failed(hook_name: &str, reason: impl Display) -> String {
19        format!("Hook '{}' call failed: {}", hook_name, reason)
20    }
21
22    /// Format error when a task execution fails (tokio/threading)
23    pub fn task_error(hook_name: &str, reason: impl Display) -> String {
24        format!("Hook '{}' task error: {}", hook_name, reason)
25    }
26
27    /// Format error when a promise/future fails
28    pub fn promise_failed(hook_name: &str, reason: impl Display) -> String {
29        format!("Hook '{}' promise failed: {}", hook_name, reason)
30    }
31
32    /// Format error for Python-specific failures
33    pub fn python_error(hook_name: &str, reason: impl Display) -> String {
34        format!("Hook '{}' Python error: {}", hook_name, reason)
35    }
36
37    /// Format error when body reading fails
38    pub fn body_read_failed(direction: &str, reason: impl Display) -> String {
39        format!("Failed to read {} body: {}", direction, reason)
40    }
41
42    /// Format error when body writing fails
43    pub fn body_write_failed(reason: impl Display) -> String {
44        format!("Failed to write body: {}", reason)
45    }
46
47    /// Format error for serialization failures
48    pub fn serialize_failed(context: &str, reason: impl Display) -> String {
49        format!("Failed to serialize {}: {}", context, reason)
50    }
51
52    /// Format error for deserialization failures
53    pub fn deserialize_failed(context: &str, reason: impl Display) -> String {
54        format!("Failed to deserialize {}: {}", context, reason)
55    }
56
57    /// Format error when building HTTP objects fails
58    pub fn build_failed(what: &str, reason: impl Display) -> String {
59        format!("Failed to build {}: {}", what, reason)
60    }
61}
62
63/// Utilities for serializing/deserializing request and response bodies
64pub mod serial {
65    use super::*;
66
67    /// Extract body bytes from an axum Body
68    pub async fn extract_body(body: Body) -> Result<bytes::Bytes, String> {
69        use axum::body::to_bytes;
70        to_bytes(body, usize::MAX)
71            .await
72            .map_err(|e| error::body_read_failed("request/response", e))
73    }
74
75    /// Create a JSON-formatted response body
76    pub fn json_response_body(json: &serde_json::Value) -> Result<Body, String> {
77        serde_json::to_string(json)
78            .map(Body::from)
79            .map_err(|e| error::serialize_failed("response JSON", e))
80    }
81
82    /// Parse a JSON value from bytes
83    pub fn parse_json(bytes: &[u8]) -> Result<serde_json::Value, String> {
84        if bytes.is_empty() {
85            return Ok(serde_json::Value::Null);
86        }
87        serde_json::from_slice(bytes)
88            .or_else(|_| Ok(serde_json::Value::String(String::from_utf8_lossy(bytes).to_string())))
89    }
90}
91
92/// Re-export of the HTTP-specific lifecycle hooks type alias
93pub use super::LifecycleHooks as HttpLifecycleHooks;
94
95/// Helper for registering hooks with standard naming conventions
96pub struct HookRegistry;
97
98impl HookRegistry {
99    /// Extract hooks from a configuration and register them with a naming pattern
100    /// Used by bindings to standardize hook naming (e.g., "on_request_hook_0")
101    pub fn register_from_list<F>(
102        hooks: &mut HttpLifecycleHooks,
103        hook_list: Vec<Arc<dyn LifecycleHook<Request<Body>, Response<Body>>>>,
104        _hook_type: &str,
105        register_fn: F,
106    ) where
107        F: Fn(&mut HttpLifecycleHooks, Arc<dyn LifecycleHook<Request<Body>, Response<Body>>>),
108    {
109        for hook in hook_list {
110            register_fn(hooks, hook);
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::lifecycle::HookResult;
119    use axum::body::Body;
120    use axum::http::{Request, Response, StatusCode};
121    use std::future::Future;
122    use std::pin::Pin;
123
124    #[test]
125    fn test_error_messages() {
126        let call_err = error::call_failed("test_hook", "test reason");
127        assert!(call_err.contains("test_hook"));
128        assert!(call_err.contains("test reason"));
129
130        let task_err = error::task_error("task_hook", "spawn failed");
131        assert!(task_err.contains("task_hook"));
132
133        let promise_err = error::promise_failed("promise_hook", "rejected");
134        assert!(promise_err.contains("promise_hook"));
135    }
136
137    #[test]
138    fn test_body_error_messages() {
139        let read_err = error::body_read_failed("request", "stream closed");
140        assert!(read_err.contains("request"));
141
142        let write_err = error::body_write_failed("allocation failed");
143        assert!(write_err.contains("allocation"));
144    }
145
146    #[test]
147    fn test_json_error_messages() {
148        let ser_err = error::serialize_failed("request body", "invalid type");
149        assert!(ser_err.contains("request body"));
150
151        let deser_err = error::deserialize_failed("response", "malformed");
152        assert!(deser_err.contains("response"));
153    }
154
155    #[tokio::test]
156    async fn serial_extract_body_roundtrips_bytes() {
157        let body = Body::from("hello");
158        let bytes = serial::extract_body(body).await.expect("extract body");
159        assert_eq!(&bytes[..], b"hello");
160    }
161
162    #[test]
163    fn serial_parse_json_handles_empty_valid_and_invalid_json() {
164        let empty = serial::parse_json(&[]).expect("parse empty");
165        assert_eq!(empty, serde_json::Value::Null);
166
167        let valid = serial::parse_json(br#"{"ok":true}"#).expect("parse json");
168        assert_eq!(valid["ok"], true);
169
170        let invalid = serial::parse_json(b"not-json").expect("parse fallback");
171        assert_eq!(invalid, serde_json::Value::String("not-json".to_string()));
172    }
173
174    #[test]
175    fn hook_registry_registers_all_hooks_via_callback() {
176        struct NoopHook {
177            hook_name: String,
178        }
179
180        impl LifecycleHook<Request<Body>, Response<Body>> for NoopHook {
181            fn name(&self) -> &str {
182                &self.hook_name
183            }
184
185            fn execute_request<'a>(
186                &self,
187                req: Request<Body>,
188            ) -> Pin<Box<dyn Future<Output = Result<HookResult<Request<Body>, Response<Body>>, String>> + Send + 'a>>
189            {
190                Box::pin(async move { Ok(HookResult::Continue(req)) })
191            }
192
193            fn execute_response<'a>(
194                &self,
195                resp: Response<Body>,
196            ) -> Pin<Box<dyn Future<Output = Result<HookResult<Response<Body>, Response<Body>>, String>> + Send + 'a>>
197            {
198                Box::pin(async move { Ok(HookResult::Continue(resp)) })
199            }
200        }
201
202        let mut hooks = HttpLifecycleHooks::new();
203        assert!(hooks.is_empty());
204
205        let hook_list: Vec<Arc<dyn LifecycleHook<Request<Body>, Response<Body>>>> = vec![
206            Arc::new(NoopHook {
207                hook_name: "one".to_string(),
208            }),
209            Arc::new(NoopHook {
210                hook_name: "two".to_string(),
211            }),
212        ];
213
214        HookRegistry::register_from_list(&mut hooks, hook_list, "on_request", |hooks, hook| {
215            hooks.add_on_request(hook);
216        });
217
218        let dbg = format!("{:?}", hooks);
219        assert!(dbg.contains("on_request_count"));
220        assert!(dbg.contains("2"));
221
222        let req = Request::builder().body(Body::empty()).unwrap();
223        let result = futures::executor::block_on(hooks.execute_on_request(req)).expect("hook run");
224        assert!(matches!(result, HookResult::Continue(_)));
225
226        let resp = Response::builder().status(StatusCode::OK).body(Body::empty()).unwrap();
227        let resp = futures::executor::block_on(hooks.execute_on_response(resp)).expect("hook run");
228        assert_eq!(resp.status(), StatusCode::OK);
229    }
230}