spikard 0.3.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -6
  3. data/ext/spikard_rb/Cargo.toml +2 -2
  4. data/lib/spikard/app.rb +33 -14
  5. data/lib/spikard/testing.rb +47 -12
  6. data/lib/spikard/version.rb +1 -1
  7. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
  8. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +132 -0
  9. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +752 -0
  10. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
  11. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
  12. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +401 -0
  13. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +238 -0
  14. data/vendor/crates/spikard-bindings-shared/src/lib.rs +24 -0
  15. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +292 -0
  16. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +616 -0
  17. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +305 -0
  18. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
  19. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +351 -0
  20. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +454 -0
  21. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +383 -0
  22. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +280 -0
  23. data/vendor/crates/spikard-core/Cargo.toml +4 -4
  24. data/vendor/crates/spikard-core/src/debug.rs +64 -0
  25. data/vendor/crates/spikard-core/src/di/container.rs +3 -27
  26. data/vendor/crates/spikard-core/src/di/factory.rs +1 -5
  27. data/vendor/crates/spikard-core/src/di/graph.rs +8 -47
  28. data/vendor/crates/spikard-core/src/di/mod.rs +1 -1
  29. data/vendor/crates/spikard-core/src/di/resolved.rs +1 -7
  30. data/vendor/crates/spikard-core/src/di/value.rs +2 -4
  31. data/vendor/crates/spikard-core/src/errors.rs +30 -0
  32. data/vendor/crates/spikard-core/src/http.rs +262 -0
  33. data/vendor/crates/spikard-core/src/lib.rs +1 -1
  34. data/vendor/crates/spikard-core/src/lifecycle.rs +764 -0
  35. data/vendor/crates/spikard-core/src/metadata.rs +389 -0
  36. data/vendor/crates/spikard-core/src/parameters.rs +1962 -159
  37. data/vendor/crates/spikard-core/src/problem.rs +34 -0
  38. data/vendor/crates/spikard-core/src/request_data.rs +966 -1
  39. data/vendor/crates/spikard-core/src/router.rs +263 -2
  40. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +688 -0
  41. data/vendor/crates/spikard-core/src/{validation.rs → validation/mod.rs} +26 -268
  42. data/vendor/crates/spikard-http/Cargo.toml +12 -16
  43. data/vendor/crates/spikard-http/examples/sse-notifications.rs +148 -0
  44. data/vendor/crates/spikard-http/examples/websocket-chat.rs +92 -0
  45. data/vendor/crates/spikard-http/src/auth.rs +65 -16
  46. data/vendor/crates/spikard-http/src/background.rs +1614 -3
  47. data/vendor/crates/spikard-http/src/cors.rs +515 -0
  48. data/vendor/crates/spikard-http/src/debug.rs +65 -0
  49. data/vendor/crates/spikard-http/src/di_handler.rs +1322 -77
  50. data/vendor/crates/spikard-http/src/handler_response.rs +711 -0
  51. data/vendor/crates/spikard-http/src/handler_trait.rs +607 -5
  52. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +6 -0
  53. data/vendor/crates/spikard-http/src/lib.rs +33 -28
  54. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +81 -0
  55. data/vendor/crates/spikard-http/src/lifecycle.rs +765 -0
  56. data/vendor/crates/spikard-http/src/middleware/mod.rs +372 -117
  57. data/vendor/crates/spikard-http/src/middleware/multipart.rs +836 -10
  58. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +409 -43
  59. data/vendor/crates/spikard-http/src/middleware/validation.rs +513 -65
  60. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +345 -0
  61. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +1055 -0
  62. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +473 -3
  63. data/vendor/crates/spikard-http/src/query_parser.rs +455 -31
  64. data/vendor/crates/spikard-http/src/response.rs +321 -0
  65. data/vendor/crates/spikard-http/src/server/handler.rs +1572 -9
  66. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +136 -0
  67. data/vendor/crates/spikard-http/src/server/mod.rs +875 -178
  68. data/vendor/crates/spikard-http/src/server/request_extraction.rs +674 -23
  69. data/vendor/crates/spikard-http/src/server/routing_factory.rs +599 -0
  70. data/vendor/crates/spikard-http/src/sse.rs +983 -21
  71. data/vendor/crates/spikard-http/src/testing/form.rs +38 -0
  72. data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -2
  73. data/vendor/crates/spikard-http/src/testing.rs +7 -7
  74. data/vendor/crates/spikard-http/src/websocket.rs +1055 -4
  75. data/vendor/crates/spikard-http/tests/background_behavior.rs +832 -0
  76. data/vendor/crates/spikard-http/tests/common/handlers.rs +309 -0
  77. data/vendor/crates/spikard-http/tests/common/mod.rs +26 -0
  78. data/vendor/crates/spikard-http/tests/di_integration.rs +192 -0
  79. data/vendor/crates/spikard-http/tests/doc_snippets.rs +5 -0
  80. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1093 -0
  81. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +656 -0
  82. data/vendor/crates/spikard-http/tests/server_config_builder.rs +314 -0
  83. data/vendor/crates/spikard-http/tests/sse_behavior.rs +620 -0
  84. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +663 -0
  85. data/vendor/crates/spikard-rb/Cargo.toml +10 -4
  86. data/vendor/crates/spikard-rb/build.rs +196 -5
  87. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
  88. data/vendor/crates/spikard-rb/src/{config.rs → config/server_config.rs} +100 -109
  89. data/vendor/crates/spikard-rb/src/conversion.rs +121 -20
  90. data/vendor/crates/spikard-rb/src/di/builder.rs +100 -0
  91. data/vendor/crates/spikard-rb/src/{di.rs → di/mod.rs} +12 -46
  92. data/vendor/crates/spikard-rb/src/handler.rs +100 -107
  93. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
  94. data/vendor/crates/spikard-rb/src/lib.rs +467 -1428
  95. data/vendor/crates/spikard-rb/src/lifecycle.rs +1 -0
  96. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
  97. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +447 -0
  98. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
  99. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +324 -0
  100. data/vendor/crates/spikard-rb/src/server.rs +47 -22
  101. data/vendor/crates/spikard-rb/src/{test_client.rs → testing/client.rs} +187 -40
  102. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
  103. data/vendor/crates/spikard-rb/src/testing/websocket.rs +635 -0
  104. data/vendor/crates/spikard-rb/src/websocket.rs +178 -37
  105. metadata +46 -13
  106. data/vendor/crates/spikard-http/src/parameters.rs +0 -1
  107. data/vendor/crates/spikard-http/src/problem.rs +0 -1
  108. data/vendor/crates/spikard-http/src/router.rs +0 -1
  109. data/vendor/crates/spikard-http/src/schema_registry.rs +0 -1
  110. data/vendor/crates/spikard-http/src/type_hints.rs +0 -1
  111. data/vendor/crates/spikard-http/src/validation.rs +0 -1
  112. data/vendor/crates/spikard-rb/src/test_websocket.rs +0 -221
  113. /data/vendor/crates/spikard-rb/src/{test_sse.rs → testing/sse.rs} +0 -0
@@ -0,0 +1,616 @@
1
+ //! Shared lifecycle hook executor infrastructure
2
+ //!
3
+ //! This module provides a language-agnostic abstraction for executing lifecycle hooks.
4
+ //! It extracts ~960 lines of duplicated logic from Python, Node.js, Ruby, and PHP bindings
5
+ //! into reusable trait-based components.
6
+ //!
7
+ //! # Design
8
+ //!
9
+ //! The executor uses a trait-based pattern where language bindings implement
10
+ //! `LanguageLifecycleHook` to provide language-specific hook invocation, while
11
+ //! `LifecycleExecutor` handles the common logic:
12
+ //!
13
+ //! - Hook result type handling (Continue vs ShortCircuit)
14
+ //! - Response/Request building from hook results
15
+ //! - Error handling and conversion
16
+ //!
17
+ //! # Example
18
+ //!
19
+ //! ```ignore
20
+ //! struct MyLanguageHook { ... }
21
+ //!
22
+ //! impl LanguageLifecycleHook for MyLanguageHook {
23
+ //! fn prepare_hook_data(&self, req: &Request<Body>) -> Result<Self::HookData, String> {
24
+ //! // Convert Request<Body> to language-specific representation
25
+ //! }
26
+ //!
27
+ //! async fn invoke_hook(&self, data: Self::HookData) -> Result<HookResultData, String> {
28
+ //! // Call language function and return structured result
29
+ //! }
30
+ //! }
31
+ //! ```
32
+
33
+ use axum::{
34
+ body::Body,
35
+ http::{Request, Response, StatusCode},
36
+ };
37
+ use std::collections::HashMap;
38
+ use std::future::Future;
39
+ use std::pin::Pin;
40
+ use std::sync::Arc;
41
+
42
+ /// Data returned from language-specific hook invocation
43
+ ///
44
+ /// This is a normalized representation of hook results that abstracts away
45
+ /// language-specific details. Bindings convert their native hook results
46
+ /// into this common format.
47
+ #[derive(Debug, Clone)]
48
+ pub struct HookResultData {
49
+ /// Whether to continue execution (true) or short-circuit (false)
50
+ pub continue_execution: bool,
51
+ /// Optional status code for short-circuit responses
52
+ pub status_code: Option<u16>,
53
+ /// Optional headers to include in response
54
+ pub headers: Option<HashMap<String, String>>,
55
+ /// Optional body bytes for response
56
+ pub body: Option<Vec<u8>>,
57
+ /// Optional request modifications (method, path, headers, body)
58
+ pub request_modifications: Option<RequestModifications>,
59
+ }
60
+
61
+ /// Modifications to apply to a request
62
+ #[derive(Debug, Clone)]
63
+ pub struct RequestModifications {
64
+ /// New HTTP method (e.g., "GET", "POST")
65
+ pub method: Option<String>,
66
+ /// New request path
67
+ pub path: Option<String>,
68
+ /// New or updated headers
69
+ pub headers: Option<HashMap<String, String>>,
70
+ /// New request body
71
+ pub body: Option<Vec<u8>>,
72
+ }
73
+
74
+ impl HookResultData {
75
+ /// Create a Continue result (pass through)
76
+ pub fn continue_execution() -> Self {
77
+ Self {
78
+ continue_execution: true,
79
+ status_code: None,
80
+ headers: None,
81
+ body: None,
82
+ request_modifications: None,
83
+ }
84
+ }
85
+
86
+ /// Create a short-circuit response result
87
+ pub fn short_circuit(status_code: u16, body: Vec<u8>, headers: Option<HashMap<String, String>>) -> Self {
88
+ Self {
89
+ continue_execution: false,
90
+ status_code: Some(status_code),
91
+ headers,
92
+ body: Some(body),
93
+ request_modifications: None,
94
+ }
95
+ }
96
+
97
+ /// Create a request modification result
98
+ pub fn modify_request(modifications: RequestModifications) -> Self {
99
+ Self {
100
+ continue_execution: true,
101
+ status_code: None,
102
+ headers: None,
103
+ body: None,
104
+ request_modifications: Some(modifications),
105
+ }
106
+ }
107
+ }
108
+
109
+ /// Trait for language-specific lifecycle hook implementations
110
+ ///
111
+ /// Each language binding implements this trait to provide language-specific
112
+ /// hook invocation while delegating common logic to `LifecycleExecutor`.
113
+ pub trait LanguageLifecycleHook: Send + Sync {
114
+ /// Language-specific hook data type
115
+ type HookData: Send;
116
+
117
+ /// Prepare hook data from the incoming request/response
118
+ ///
119
+ /// This should convert axum HTTP types to language-specific representations.
120
+ fn prepare_hook_data(&self, req: &Request<Body>) -> Result<Self::HookData, String>;
121
+
122
+ /// Invoke the language hook and return normalized result data
123
+ ///
124
+ /// This should call the language function and convert its result to `HookResultData`.
125
+ fn invoke_hook(&self, data: Self::HookData)
126
+ -> Pin<Box<dyn Future<Output = Result<HookResultData, String>> + Send>>;
127
+ }
128
+
129
+ /// Executor that handles common lifecycle hook logic
130
+ ///
131
+ /// This executor is generic over any language binding that implements
132
+ /// `LanguageLifecycleHook`. It provides common logic for:
133
+ /// - Executing request hooks and handling short-circuits
134
+ /// - Executing response hooks and building modified responses
135
+ /// - Converting hook results to axum Request/Response types
136
+ pub struct LifecycleExecutor<L: LanguageLifecycleHook> {
137
+ hook: Arc<L>,
138
+ }
139
+
140
+ impl<L: LanguageLifecycleHook> LifecycleExecutor<L> {
141
+ /// Create a new executor for the given hook
142
+ pub fn new(hook: Arc<L>) -> Self {
143
+ Self { hook }
144
+ }
145
+
146
+ /// Execute a request hook, handling Continue/ShortCircuit semantics
147
+ ///
148
+ /// Returns either the modified request or a short-circuit response.
149
+ pub async fn execute_request_hook(
150
+ &self,
151
+ req: Request<Body>,
152
+ ) -> Result<Result<Request<Body>, Response<Body>>, String> {
153
+ let hook_data = self.hook.prepare_hook_data(&req)?;
154
+ let result = self.hook.invoke_hook(hook_data).await?;
155
+
156
+ if !result.continue_execution {
157
+ let response = self.build_response_from_hook_result(&result)?;
158
+ return Ok(Err(response));
159
+ }
160
+
161
+ if let Some(modifications) = result.request_modifications {
162
+ let modified_req = self.apply_request_modifications(req, modifications)?;
163
+ Ok(Ok(modified_req))
164
+ } else {
165
+ Ok(Ok(req))
166
+ }
167
+ }
168
+
169
+ /// Execute a response hook, handling response modification
170
+ ///
171
+ /// Response hooks can only continue or modify the response,
172
+ /// never short-circuit.
173
+ pub async fn execute_response_hook(&self, resp: Response<Body>) -> Result<Response<Body>, String> {
174
+ let (parts, body) = resp.into_parts();
175
+ let body_bytes = extract_body(body).await?;
176
+
177
+ let dummy_req = Request::builder()
178
+ .method("GET")
179
+ .uri("/")
180
+ .body(Body::empty())
181
+ .map_err(|e| format!("Failed to build dummy request: {}", e))?;
182
+
183
+ let hook_data = self.hook.prepare_hook_data(&dummy_req)?;
184
+ let result = self.hook.invoke_hook(hook_data).await?;
185
+
186
+ if let Some(modifications) = result.request_modifications {
187
+ let mut builder = Response::builder().status(parts.status);
188
+
189
+ let header_mod_keys: Vec<String> = modifications
190
+ .headers
191
+ .as_ref()
192
+ .map(|mods| mods.keys().map(|k| k.to_lowercase()).collect())
193
+ .unwrap_or_default();
194
+
195
+ if let Some(header_mods) = modifications.headers {
196
+ for (key, value) in header_mods {
197
+ builder = builder.header(&key, &value);
198
+ }
199
+ }
200
+
201
+ for (name, value) in parts.headers.iter() {
202
+ let key_str = name.as_str().to_lowercase();
203
+ if !header_mod_keys.contains(&key_str) {
204
+ builder = builder.header(name, value);
205
+ }
206
+ }
207
+
208
+ let body = modifications.body.unwrap_or(body_bytes);
209
+ return builder
210
+ .body(Body::from(body))
211
+ .map_err(|e| format!("Failed to build modified response: {}", e));
212
+ }
213
+
214
+ let mut builder = Response::builder().status(parts.status);
215
+ for (name, value) in parts.headers {
216
+ if let Some(name) = name {
217
+ builder = builder.header(name, value);
218
+ }
219
+ }
220
+ builder
221
+ .body(Body::from(body_bytes))
222
+ .map_err(|e| format!("Failed to rebuild response: {}", e))
223
+ }
224
+
225
+ /// Build an axum Response from hook result data
226
+ fn build_response_from_hook_result(&self, result: &HookResultData) -> Result<Response<Body>, String> {
227
+ let status_code = result.status_code.unwrap_or(200);
228
+ let status =
229
+ StatusCode::from_u16(status_code).map_err(|e| format!("Invalid status code {}: {}", status_code, e))?;
230
+
231
+ let mut builder = Response::builder().status(status);
232
+
233
+ if let Some(ref headers) = result.headers {
234
+ for (key, value) in headers {
235
+ builder = builder.header(key, value);
236
+ }
237
+ }
238
+
239
+ if !builder
240
+ .headers_ref()
241
+ .map(|h| h.contains_key("content-type"))
242
+ .unwrap_or(false)
243
+ {
244
+ builder = builder.header("content-type", "application/json");
245
+ }
246
+
247
+ let body = result.body.clone().unwrap_or_else(|| b"{}".to_vec());
248
+
249
+ builder
250
+ .body(Body::from(body))
251
+ .map_err(|e| format!("Failed to build response: {}", e))
252
+ }
253
+
254
+ /// Apply request modifications to a request
255
+ fn apply_request_modifications(
256
+ &self,
257
+ req: Request<Body>,
258
+ mods: RequestModifications,
259
+ ) -> Result<Request<Body>, String> {
260
+ let (mut parts, body) = req.into_parts();
261
+
262
+ if let Some(method) = &mods.method {
263
+ parts.method = method
264
+ .parse()
265
+ .map_err(|e| format!("Invalid method '{}': {}", method, e))?;
266
+ }
267
+
268
+ if let Some(path) = &mods.path {
269
+ parts.uri = path.parse().map_err(|e| format!("Invalid path '{}': {}", path, e))?;
270
+ }
271
+
272
+ if let Some(new_headers) = &mods.headers {
273
+ for (key, value) in new_headers {
274
+ let header_name: http::header::HeaderName =
275
+ key.parse().map_err(|_| format!("Invalid header name: {}", key))?;
276
+ let header_value: http::header::HeaderValue = value
277
+ .parse()
278
+ .map_err(|_| format!("Invalid header value for {}: {}", key, value))?;
279
+ parts.headers.insert(header_name, header_value);
280
+ }
281
+ }
282
+
283
+ let body = if let Some(new_body) = mods.body {
284
+ Body::from(new_body)
285
+ } else {
286
+ body
287
+ };
288
+
289
+ Ok(Request::from_parts(parts, body))
290
+ }
291
+ }
292
+
293
+ /// Extract body bytes from an axum Body
294
+ ///
295
+ /// This is a helper used by lifecycle executors to read response bodies.
296
+ pub async fn extract_body(body: Body) -> Result<Vec<u8>, String> {
297
+ use http_body_util::BodyExt;
298
+
299
+ let bytes = body
300
+ .collect()
301
+ .await
302
+ .map_err(|e| format!("Failed to read body: {}", e))?
303
+ .to_bytes();
304
+ Ok(bytes.to_vec())
305
+ }
306
+
307
+ #[cfg(test)]
308
+ mod tests {
309
+ use super::*;
310
+
311
+ #[test]
312
+ fn test_hook_result_data_continue() {
313
+ let result = HookResultData::continue_execution();
314
+ assert!(result.continue_execution);
315
+ assert_eq!(result.status_code, None);
316
+ assert_eq!(result.body, None);
317
+ }
318
+
319
+ #[test]
320
+ fn test_hook_result_data_short_circuit() {
321
+ let body = b"error".to_vec();
322
+ let mut headers = HashMap::new();
323
+ headers.insert("x-error".to_string(), "true".to_string());
324
+
325
+ let result = HookResultData::short_circuit(400, body.clone(), Some(headers.clone()));
326
+ assert!(!result.continue_execution);
327
+ assert_eq!(result.status_code, Some(400));
328
+ assert_eq!(result.body, Some(body));
329
+ assert_eq!(result.headers, Some(headers));
330
+ }
331
+
332
+ #[test]
333
+ fn test_hook_result_data_modify_request() {
334
+ let mods = RequestModifications {
335
+ method: Some("POST".to_string()),
336
+ path: Some("/new-path".to_string()),
337
+ headers: None,
338
+ body: None,
339
+ };
340
+
341
+ let result = HookResultData::modify_request(mods.clone());
342
+ assert!(result.continue_execution);
343
+ assert_eq!(result.status_code, None);
344
+ assert_eq!(
345
+ result.request_modifications.as_ref().unwrap().method,
346
+ Some("POST".to_string())
347
+ );
348
+ assert_eq!(
349
+ result.request_modifications.as_ref().unwrap().path,
350
+ Some("/new-path".to_string())
351
+ );
352
+ }
353
+
354
+ #[test]
355
+ fn test_request_modifications_creation() {
356
+ let mods = RequestModifications {
357
+ method: Some("PUT".to_string()),
358
+ path: Some("/api/resource".to_string()),
359
+ headers: None,
360
+ body: Some(b"data".to_vec()),
361
+ };
362
+
363
+ assert_eq!(mods.method, Some("PUT".to_string()));
364
+ assert_eq!(mods.path, Some("/api/resource".to_string()));
365
+ assert_eq!(mods.body, Some(b"data".to_vec()));
366
+ }
367
+
368
+ struct MockHook {
369
+ result: HookResultData,
370
+ }
371
+
372
+ impl LanguageLifecycleHook for MockHook {
373
+ type HookData = ();
374
+
375
+ fn prepare_hook_data(&self, _req: &Request<Body>) -> Result<Self::HookData, String> {
376
+ Ok(())
377
+ }
378
+
379
+ fn invoke_hook(
380
+ &self,
381
+ _data: Self::HookData,
382
+ ) -> Pin<Box<dyn Future<Output = Result<HookResultData, String>> + Send>> {
383
+ let result = self.result.clone();
384
+ Box::pin(async move { Ok(result) })
385
+ }
386
+ }
387
+
388
+ #[tokio::test]
389
+ async fn test_execute_request_hook_continue() {
390
+ let hook = Arc::new(MockHook {
391
+ result: HookResultData::continue_execution(),
392
+ });
393
+ let executor = LifecycleExecutor::new(hook);
394
+
395
+ let req = Request::builder().body(Body::empty()).unwrap();
396
+ let result = executor.execute_request_hook(req).await.unwrap();
397
+
398
+ assert!(result.is_ok());
399
+ }
400
+
401
+ #[tokio::test]
402
+ async fn test_execute_request_hook_short_circuit() {
403
+ let hook = Arc::new(MockHook {
404
+ result: HookResultData::short_circuit(403, b"Forbidden".to_vec(), None),
405
+ });
406
+ let executor = LifecycleExecutor::new(hook);
407
+
408
+ let req = Request::builder().body(Body::empty()).unwrap();
409
+ let result = executor.execute_request_hook(req).await.unwrap();
410
+
411
+ assert!(result.is_err());
412
+ let response = result.unwrap_err();
413
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
414
+ }
415
+
416
+ #[tokio::test]
417
+ async fn test_execute_request_hook_modify_request() {
418
+ let mods = RequestModifications {
419
+ method: Some("POST".to_string()),
420
+ path: Some("/new-path".to_string()),
421
+ headers: None,
422
+ body: Some(b"new body".to_vec()),
423
+ };
424
+ let hook = Arc::new(MockHook {
425
+ result: HookResultData::modify_request(mods),
426
+ });
427
+ let executor = LifecycleExecutor::new(hook);
428
+
429
+ let req = Request::builder()
430
+ .method("GET")
431
+ .uri("/old-path")
432
+ .body(Body::empty())
433
+ .unwrap();
434
+ let result = executor.execute_request_hook(req).await.unwrap();
435
+
436
+ assert!(result.is_ok());
437
+ let modified_req = result.unwrap();
438
+ assert_eq!(modified_req.method(), "POST");
439
+ assert_eq!(modified_req.uri().path(), "/new-path");
440
+ }
441
+
442
+ #[tokio::test]
443
+ async fn test_execute_response_hook_with_modifications() {
444
+ let mods = RequestModifications {
445
+ method: None,
446
+ path: None,
447
+ headers: Some({
448
+ let mut h = HashMap::new();
449
+ h.insert("X-Modified".to_string(), "true".to_string());
450
+ h
451
+ }),
452
+ body: Some(b"modified response".to_vec()),
453
+ };
454
+ let hook = Arc::new(MockHook {
455
+ result: HookResultData::modify_request(mods),
456
+ });
457
+ let executor = LifecycleExecutor::new(hook);
458
+
459
+ let resp = Response::builder().status(200).body(Body::from("original")).unwrap();
460
+ let result = executor.execute_response_hook(resp).await.unwrap();
461
+
462
+ assert_eq!(result.status(), StatusCode::OK);
463
+ assert_eq!(result.headers().get("X-Modified").unwrap().to_str().unwrap(), "true");
464
+ }
465
+
466
+ #[tokio::test]
467
+ async fn test_build_response_from_hook_result_with_headers() {
468
+ let mut headers = HashMap::new();
469
+ headers.insert("X-Custom".to_string(), "value".to_string());
470
+
471
+ let result = HookResultData::short_circuit(201, b"Created".to_vec(), Some(headers));
472
+ let hook = Arc::new(MockHook {
473
+ result: HookResultData::continue_execution(),
474
+ });
475
+ let executor = LifecycleExecutor::new(hook);
476
+
477
+ let response = executor.build_response_from_hook_result(&result).unwrap();
478
+ assert_eq!(response.status(), StatusCode::CREATED);
479
+ assert_eq!(response.headers().get("X-Custom").unwrap().to_str().unwrap(), "value");
480
+ }
481
+
482
+ #[tokio::test]
483
+ async fn test_build_response_from_hook_result_default_content_type() {
484
+ let result = HookResultData::short_circuit(200, b"{}".to_vec(), None);
485
+ let hook = Arc::new(MockHook {
486
+ result: HookResultData::continue_execution(),
487
+ });
488
+ let executor = LifecycleExecutor::new(hook);
489
+
490
+ let response = executor.build_response_from_hook_result(&result).unwrap();
491
+ assert_eq!(
492
+ response.headers().get("content-type").unwrap().to_str().unwrap(),
493
+ "application/json"
494
+ );
495
+ }
496
+
497
+ #[tokio::test]
498
+ async fn test_apply_request_modifications_method() {
499
+ let mods = RequestModifications {
500
+ method: Some("PATCH".to_string()),
501
+ path: None,
502
+ headers: None,
503
+ body: None,
504
+ };
505
+ let hook = Arc::new(MockHook {
506
+ result: HookResultData::continue_execution(),
507
+ });
508
+ let executor = LifecycleExecutor::new(hook);
509
+
510
+ let req = Request::builder().method("GET").body(Body::empty()).unwrap();
511
+ let modified = executor.apply_request_modifications(req, mods).unwrap();
512
+
513
+ assert_eq!(modified.method(), "PATCH");
514
+ }
515
+
516
+ #[tokio::test]
517
+ async fn test_apply_request_modifications_path() {
518
+ let mods = RequestModifications {
519
+ method: None,
520
+ path: Some("/api/v2/users".to_string()),
521
+ headers: None,
522
+ body: None,
523
+ };
524
+ let hook = Arc::new(MockHook {
525
+ result: HookResultData::continue_execution(),
526
+ });
527
+ let executor = LifecycleExecutor::new(hook);
528
+
529
+ let req = Request::builder().uri("/api/v1/users").body(Body::empty()).unwrap();
530
+ let modified = executor.apply_request_modifications(req, mods).unwrap();
531
+
532
+ assert_eq!(modified.uri().path(), "/api/v2/users");
533
+ }
534
+
535
+ #[tokio::test]
536
+ async fn test_apply_request_modifications_headers() {
537
+ let mut new_headers = HashMap::new();
538
+ new_headers.insert("Authorization".to_string(), "Bearer token".to_string());
539
+
540
+ let mods = RequestModifications {
541
+ method: None,
542
+ path: None,
543
+ headers: Some(new_headers),
544
+ body: None,
545
+ };
546
+ let hook = Arc::new(MockHook {
547
+ result: HookResultData::continue_execution(),
548
+ });
549
+ let executor = LifecycleExecutor::new(hook);
550
+
551
+ let req = Request::builder().body(Body::empty()).unwrap();
552
+ let modified = executor.apply_request_modifications(req, mods).unwrap();
553
+
554
+ assert_eq!(
555
+ modified.headers().get("Authorization").unwrap().to_str().unwrap(),
556
+ "Bearer token"
557
+ );
558
+ }
559
+
560
+ #[tokio::test]
561
+ async fn test_apply_request_modifications_body() {
562
+ let new_body = b"modified body".to_vec();
563
+ let mods = RequestModifications {
564
+ method: None,
565
+ path: None,
566
+ headers: None,
567
+ body: Some(new_body.clone()),
568
+ };
569
+ let hook = Arc::new(MockHook {
570
+ result: HookResultData::continue_execution(),
571
+ });
572
+ let executor = LifecycleExecutor::new(hook);
573
+
574
+ let req = Request::builder().body(Body::from("original body")).unwrap();
575
+ let modified = executor.apply_request_modifications(req, mods).unwrap();
576
+
577
+ let body_bytes = extract_body(modified.into_body()).await.unwrap();
578
+ assert_eq!(body_bytes, new_body);
579
+ }
580
+
581
+ #[tokio::test]
582
+ async fn test_apply_request_modifications_invalid_method() {
583
+ let mods = RequestModifications {
584
+ method: Some("".to_string()),
585
+ path: None,
586
+ headers: None,
587
+ body: None,
588
+ };
589
+ let hook = Arc::new(MockHook {
590
+ result: HookResultData::continue_execution(),
591
+ });
592
+ let executor = LifecycleExecutor::new(hook);
593
+
594
+ let req = Request::builder().body(Body::empty()).unwrap();
595
+ let result = executor.apply_request_modifications(req, mods);
596
+
597
+ assert!(result.is_err());
598
+ assert!(result.unwrap_err().contains("Invalid method"));
599
+ }
600
+
601
+ #[tokio::test]
602
+ async fn test_extract_body_helper() {
603
+ let body = Body::from("test data");
604
+ let bytes = extract_body(body).await.unwrap();
605
+
606
+ assert_eq!(bytes, b"test data");
607
+ }
608
+
609
+ #[tokio::test]
610
+ async fn test_extract_body_empty() {
611
+ let body = Body::empty();
612
+ let bytes = extract_body(body).await.unwrap();
613
+
614
+ assert_eq!(bytes.len(), 0);
615
+ }
616
+ }