spikard 0.6.2 → 0.7.2

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -508
  3. data/ext/spikard_rb/Cargo.lock +3287 -0
  4. data/ext/spikard_rb/Cargo.toml +1 -1
  5. data/ext/spikard_rb/extconf.rb +3 -3
  6. data/lib/spikard/app.rb +72 -49
  7. data/lib/spikard/background.rb +38 -7
  8. data/lib/spikard/testing.rb +42 -4
  9. data/lib/spikard/version.rb +1 -1
  10. data/sig/spikard.rbs +4 -0
  11. data/vendor/crates/spikard-bindings-shared/Cargo.toml +2 -2
  12. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
  13. data/vendor/crates/spikard-core/Cargo.toml +1 -1
  14. data/vendor/crates/spikard-core/src/http.rs +1 -0
  15. data/vendor/crates/spikard-core/src/lifecycle.rs +63 -0
  16. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +136 -0
  17. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +37 -0
  18. data/vendor/crates/spikard-core/tests/error_mapper.rs +761 -0
  19. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +106 -0
  20. data/vendor/crates/spikard-core/tests/parameters_full.rs +701 -0
  21. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +301 -0
  22. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +67 -0
  23. data/vendor/crates/spikard-core/tests/validation_coverage.rs +250 -0
  24. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +45 -0
  25. data/vendor/crates/spikard-http/Cargo.toml +1 -1
  26. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +502 -0
  27. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +648 -0
  28. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +58 -0
  29. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +1207 -0
  30. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2262 -0
  31. data/vendor/crates/spikard-http/src/testing/test_client.rs +155 -2
  32. data/vendor/crates/spikard-http/src/testing.rs +171 -0
  33. data/vendor/crates/spikard-http/src/websocket.rs +79 -6
  34. data/vendor/crates/spikard-http/tests/auth_integration.rs +647 -0
  35. data/vendor/crates/spikard-http/tests/common/test_builders.rs +633 -0
  36. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +162 -0
  37. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +389 -0
  38. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +513 -0
  39. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +244 -0
  40. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +200 -0
  41. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +82 -0
  42. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +464 -0
  43. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +286 -0
  44. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +118 -0
  45. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +99 -0
  46. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +206 -0
  47. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +281 -0
  48. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +121 -0
  49. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +584 -0
  50. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +130 -0
  51. data/vendor/crates/spikard-http/tests/test_client_requests.rs +167 -0
  52. data/vendor/crates/spikard-http/tests/testing_helpers.rs +87 -0
  53. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +156 -0
  54. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +82 -0
  55. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +440 -0
  56. data/vendor/crates/spikard-http/tests/websocket_integration.rs +152 -0
  57. data/vendor/crates/spikard-rb/Cargo.toml +1 -1
  58. data/vendor/crates/spikard-rb/src/gvl.rs +80 -0
  59. data/vendor/crates/spikard-rb/src/handler.rs +12 -9
  60. data/vendor/crates/spikard-rb/src/lib.rs +137 -124
  61. data/vendor/crates/spikard-rb/src/request.rs +342 -0
  62. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +1 -8
  63. data/vendor/crates/spikard-rb/src/server.rs +1 -8
  64. data/vendor/crates/spikard-rb/src/testing/client.rs +168 -9
  65. data/vendor/crates/spikard-rb/src/websocket.rs +119 -30
  66. data/vendor/crates/spikard-rb-macros/Cargo.toml +14 -0
  67. data/vendor/crates/spikard-rb-macros/src/lib.rs +52 -0
  68. metadata +44 -1
@@ -0,0 +1,206 @@
1
+ use axum::body::Body;
2
+ use axum::http::{Request, StatusCode};
3
+ use brotli::Decompressor;
4
+ use spikard_http::server::build_router_with_handlers_and_config;
5
+ use spikard_http::{
6
+ CompressionConfig, Handler, HandlerResult, Method, RateLimitConfig, RequestData, Route, ServerConfig,
7
+ };
8
+ use std::future::Future;
9
+ use std::io::Read;
10
+ use std::pin::Pin;
11
+ use std::sync::Arc;
12
+ use uuid::Uuid;
13
+
14
+ struct PlainTextHandler {
15
+ body: String,
16
+ }
17
+
18
+ impl Handler for PlainTextHandler {
19
+ fn call(
20
+ &self,
21
+ _request: Request<Body>,
22
+ _request_data: RequestData,
23
+ ) -> Pin<Box<dyn Future<Output = HandlerResult> + Send + '_>> {
24
+ let body = self.body.clone();
25
+ Box::pin(async move {
26
+ Ok(axum::http::Response::builder()
27
+ .status(StatusCode::OK)
28
+ .header("content-type", "text/plain")
29
+ .body(Body::from(body))
30
+ .expect("response builder"))
31
+ })
32
+ }
33
+ }
34
+
35
+ fn basic_route(method: Method, path: &str, expects_json_body: bool) -> Route {
36
+ Route {
37
+ method,
38
+ path: path.to_string(),
39
+ handler_name: "plain".to_string(),
40
+ expects_json_body,
41
+ cors: None,
42
+ is_async: true,
43
+ file_params: None,
44
+ request_validator: None,
45
+ response_validator: None,
46
+ parameter_validator: None,
47
+ jsonrpc_method: None,
48
+ #[cfg(feature = "di")]
49
+ handler_dependencies: Vec::new(),
50
+ }
51
+ }
52
+
53
+ #[tokio::test]
54
+ async fn request_id_is_generated_and_propagated() {
55
+ let route = basic_route(Method::Get, "/rid", false);
56
+ let config = ServerConfig {
57
+ enable_request_id: true,
58
+ ..Default::default()
59
+ };
60
+ let router = build_router_with_handlers_and_config(
61
+ vec![(
62
+ route,
63
+ Arc::new(PlainTextHandler { body: "ok".to_string() }) as Arc<dyn Handler>,
64
+ )],
65
+ config,
66
+ Vec::new(),
67
+ )
68
+ .expect("router");
69
+
70
+ let server = axum_test::TestServer::new(router).expect("server");
71
+ let response = server.get("/rid").await;
72
+ assert_eq!(response.status_code(), StatusCode::OK);
73
+
74
+ let header = response.header("x-request-id");
75
+ let request_id = header.to_str().expect("request id");
76
+ assert!(Uuid::parse_str(request_id).is_ok());
77
+
78
+ let response2 = server.get("/rid").add_header("x-request-id", "req-123").await;
79
+ assert_eq!(response2.status_code(), StatusCode::OK);
80
+ assert_eq!(
81
+ response2.header("x-request-id").to_str().expect("request id"),
82
+ "req-123"
83
+ );
84
+ }
85
+
86
+ #[tokio::test]
87
+ async fn default_body_limit_can_be_disabled() {
88
+ let route = basic_route(Method::Post, "/upload", false);
89
+ let config = ServerConfig {
90
+ max_body_size: None,
91
+ ..Default::default()
92
+ };
93
+
94
+ let router = build_router_with_handlers_and_config(
95
+ vec![(
96
+ route,
97
+ Arc::new(PlainTextHandler { body: "ok".to_string() }) as Arc<dyn Handler>,
98
+ )],
99
+ config,
100
+ Vec::new(),
101
+ )
102
+ .expect("router");
103
+
104
+ let server = axum_test::TestServer::new(router).expect("server");
105
+ let payload = vec![b'a'; 1024 * 128];
106
+ let response = server.post("/upload").bytes(payload.into()).await;
107
+ assert_eq!(response.status_code(), StatusCode::OK);
108
+ }
109
+
110
+ #[tokio::test]
111
+ async fn default_body_limit_allows_payloads_within_limit() {
112
+ let route = basic_route(Method::Post, "/upload", false);
113
+ let config = ServerConfig {
114
+ max_body_size: Some(16),
115
+ ..Default::default()
116
+ };
117
+
118
+ let router = build_router_with_handlers_and_config(
119
+ vec![(
120
+ route,
121
+ Arc::new(PlainTextHandler { body: "ok".to_string() }) as Arc<dyn Handler>,
122
+ )],
123
+ config,
124
+ Vec::new(),
125
+ )
126
+ .expect("router");
127
+
128
+ let server = axum_test::TestServer::new(router).expect("server");
129
+ let payload = vec![b'a'; 8];
130
+ let response = server.post("/upload").bytes(payload.into()).await;
131
+ assert_eq!(response.status_code(), StatusCode::OK);
132
+ }
133
+
134
+ #[tokio::test]
135
+ async fn compression_br_is_applied_when_accepted() {
136
+ let original_body = "x".repeat(2048);
137
+ let route = basic_route(Method::Get, "/compressed", false);
138
+
139
+ let config = ServerConfig {
140
+ compression: Some(CompressionConfig {
141
+ gzip: false,
142
+ brotli: true,
143
+ min_size: 0,
144
+ quality: 3,
145
+ }),
146
+ ..Default::default()
147
+ };
148
+
149
+ let router = build_router_with_handlers_and_config(
150
+ vec![(
151
+ route,
152
+ Arc::new(PlainTextHandler {
153
+ body: original_body.clone(),
154
+ }) as Arc<dyn Handler>,
155
+ )],
156
+ config,
157
+ Vec::new(),
158
+ )
159
+ .expect("router");
160
+
161
+ let server = axum_test::TestServer::new(router).expect("server");
162
+ let response = server.get("/compressed").add_header("accept-encoding", "br").await;
163
+ assert_eq!(response.status_code(), StatusCode::OK);
164
+ assert_eq!(response.header("content-encoding").to_str().expect("encoding"), "br");
165
+
166
+ let mut decoder = Decompressor::new(response.as_bytes().as_ref(), 4096);
167
+ let mut decoded_body = String::new();
168
+ decoder.read_to_string(&mut decoded_body).expect("decompress");
169
+ assert_eq!(decoded_body, original_body);
170
+ }
171
+
172
+ #[tokio::test]
173
+ async fn rate_limit_builder_covers_ip_and_global_key_extractors() {
174
+ let route = basic_route(Method::Get, "/rl", false);
175
+ let handler: Arc<dyn Handler> = Arc::new(PlainTextHandler { body: "ok".to_string() });
176
+
177
+ let ip_config = ServerConfig {
178
+ rate_limit: Some(RateLimitConfig {
179
+ per_second: 100,
180
+ burst: 10,
181
+ ip_based: true,
182
+ }),
183
+ ..Default::default()
184
+ };
185
+ let router_ip =
186
+ build_router_with_handlers_and_config(vec![(route.clone(), Arc::clone(&handler))], ip_config, Vec::new())
187
+ .expect("router");
188
+ let server_ip = axum_test::TestServer::new(router_ip.into_make_service_with_connect_info::<std::net::SocketAddr>())
189
+ .expect("server");
190
+ assert_eq!(server_ip.get("/rl").await.status_code(), StatusCode::OK);
191
+
192
+ let global_config = ServerConfig {
193
+ rate_limit: Some(RateLimitConfig {
194
+ per_second: 100,
195
+ burst: 10,
196
+ ip_based: false,
197
+ }),
198
+ ..Default::default()
199
+ };
200
+ let router_global =
201
+ build_router_with_handlers_and_config(vec![(route, handler)], global_config, Vec::new()).expect("router");
202
+ let server_global =
203
+ axum_test::TestServer::new(router_global.into_make_service_with_connect_info::<std::net::SocketAddr>())
204
+ .expect("server");
205
+ assert_eq!(server_global.get("/rl").await.status_code(), StatusCode::OK);
206
+ }
@@ -0,0 +1,281 @@
1
+ use axum::body::Body;
2
+ use axum::http::{Request, StatusCode};
3
+ use serde_json::Value;
4
+ use spikard_core::router::JsonRpcMethodInfo;
5
+ use spikard_http::server::build_router_with_handlers_and_config;
6
+ use spikard_http::{
7
+ Handler, HandlerResult, JsonRpcConfig, Method, OpenApiConfig, RateLimitConfig, RequestData, Route, RouteMetadata,
8
+ ServerConfig, StaticFilesConfig,
9
+ };
10
+ use std::future::Future;
11
+ use std::path::Path;
12
+ use std::pin::Pin;
13
+ use std::sync::Arc;
14
+
15
+ struct EchoHandler;
16
+
17
+ impl Handler for EchoHandler {
18
+ fn call(
19
+ &self,
20
+ _request: Request<Body>,
21
+ request_data: RequestData,
22
+ ) -> Pin<Box<dyn Future<Output = HandlerResult> + Send + '_>> {
23
+ Box::pin(async move {
24
+ let response_json = serde_json::json!({
25
+ "method": request_data.method,
26
+ "path": request_data.path,
27
+ "path_params": &*request_data.path_params,
28
+ "body": request_data.body,
29
+ "raw_body_json": request_data
30
+ .raw_body
31
+ .as_ref()
32
+ .and_then(|bytes| serde_json::from_slice::<Value>(bytes.as_ref()).ok()),
33
+ });
34
+
35
+ Ok(axum::http::Response::builder()
36
+ .status(StatusCode::OK)
37
+ .header("content-type", "application/json")
38
+ .body(Body::from(response_json.to_string()))
39
+ .expect("response builder"))
40
+ })
41
+ }
42
+ }
43
+
44
+ fn api_items_path() -> String {
45
+ ["api/items/", "{", "id:uuid", "}"].concat()
46
+ }
47
+
48
+ fn build_routes(path: &str) -> Vec<(Route, Arc<dyn Handler>)> {
49
+ vec![
50
+ (
51
+ Route {
52
+ method: Method::Get,
53
+ path: path.to_string(),
54
+ handler_name: "echo_get".to_string(),
55
+ expects_json_body: false,
56
+ cors: None,
57
+ is_async: true,
58
+ file_params: None,
59
+ request_validator: None,
60
+ response_validator: None,
61
+ parameter_validator: None,
62
+ jsonrpc_method: Some(JsonRpcMethodInfo {
63
+ method_name: "spikard.test.echo".to_string(),
64
+ description: Some("Echo JSON-RPC".to_string()),
65
+ params_schema: None,
66
+ result_schema: None,
67
+ deprecated: false,
68
+ tags: vec!["test".to_string()],
69
+ }),
70
+ #[cfg(feature = "di")]
71
+ handler_dependencies: Vec::new(),
72
+ },
73
+ Arc::new(EchoHandler) as Arc<dyn Handler>,
74
+ ),
75
+ (
76
+ Route {
77
+ method: Method::Post,
78
+ path: path.to_string(),
79
+ handler_name: "echo_post".to_string(),
80
+ expects_json_body: true,
81
+ cors: None,
82
+ is_async: true,
83
+ file_params: None,
84
+ request_validator: None,
85
+ response_validator: None,
86
+ parameter_validator: None,
87
+ jsonrpc_method: None,
88
+ #[cfg(feature = "di")]
89
+ handler_dependencies: Vec::new(),
90
+ },
91
+ Arc::new(EchoHandler) as Arc<dyn Handler>,
92
+ ),
93
+ ]
94
+ }
95
+
96
+ fn build_route_metadata(path: &str) -> Vec<RouteMetadata> {
97
+ let request_schema = serde_json::json!({
98
+ "type": "object",
99
+ "properties": {
100
+ "name": {"type": "string"}
101
+ },
102
+ "required": ["name"]
103
+ });
104
+
105
+ let response_schema = serde_json::json!({
106
+ "type": "object",
107
+ "properties": {
108
+ "ok": {"type": "boolean"}
109
+ },
110
+ "required": ["ok"]
111
+ });
112
+
113
+ vec![
114
+ RouteMetadata {
115
+ method: "GET".to_string(),
116
+ path: path.to_string(),
117
+ handler_name: "echo_get".to_string(),
118
+ request_schema: None,
119
+ response_schema: None,
120
+ parameter_schema: None,
121
+ file_params: None,
122
+ is_async: true,
123
+ cors: None,
124
+ body_param_name: None,
125
+ #[cfg(feature = "di")]
126
+ handler_dependencies: None,
127
+ jsonrpc_method: Some(
128
+ serde_json::to_value(JsonRpcMethodInfo {
129
+ method_name: "spikard.test.echo".to_string(),
130
+ description: Some("Echo JSON-RPC".to_string()),
131
+ params_schema: None,
132
+ result_schema: None,
133
+ deprecated: false,
134
+ tags: vec!["test".to_string()],
135
+ })
136
+ .expect("jsonrpc method info"),
137
+ ),
138
+ },
139
+ RouteMetadata {
140
+ method: "POST".to_string(),
141
+ path: path.to_string(),
142
+ handler_name: "echo_post".to_string(),
143
+ request_schema: Some(request_schema),
144
+ response_schema: Some(response_schema),
145
+ parameter_schema: None,
146
+ file_params: None,
147
+ is_async: true,
148
+ cors: None,
149
+ body_param_name: Some("body".to_string()),
150
+ #[cfg(feature = "di")]
151
+ handler_dependencies: None,
152
+ jsonrpc_method: None,
153
+ },
154
+ ]
155
+ }
156
+
157
+ fn build_config(static_dir: &Path) -> ServerConfig {
158
+ ServerConfig {
159
+ openapi: Some(OpenApiConfig {
160
+ enabled: true,
161
+ title: "Spikard Test API".to_string(),
162
+ version: "0.1.0".to_string(),
163
+ ..OpenApiConfig::default()
164
+ }),
165
+ jsonrpc: Some(JsonRpcConfig::default()),
166
+ static_files: vec![StaticFilesConfig {
167
+ directory: static_dir.to_string_lossy().into_owned(),
168
+ route_prefix: "/static".to_string(),
169
+ index_file: true,
170
+ cache_control: Some("public, max-age=60".to_string()),
171
+ }],
172
+ rate_limit: Some(RateLimitConfig {
173
+ per_second: 100,
174
+ burst: 10,
175
+ ip_based: false,
176
+ }),
177
+ ..Default::default()
178
+ }
179
+ }
180
+
181
+ async fn assert_openapi_docs_and_redoc(server: &axum_test::TestServer) {
182
+ let openapi_response = server.get("/openapi.json").await;
183
+ assert_eq!(openapi_response.status_code(), StatusCode::OK);
184
+ let openapi: Value = serde_json::from_str(&openapi_response.text()).expect("openapi json");
185
+ assert!(openapi.get("openapi").is_some());
186
+ assert!(openapi.get("paths").is_some());
187
+
188
+ let swagger_html = server.get("/docs").await;
189
+ assert_eq!(swagger_html.status_code(), StatusCode::OK);
190
+ assert!(swagger_html.text().contains("SwaggerUIBundle"));
191
+ assert!(swagger_html.text().contains("/openapi.json"));
192
+
193
+ let redoc_html = server.get("/redoc").await;
194
+ assert_eq!(redoc_html.status_code(), StatusCode::OK);
195
+ assert!(redoc_html.text().contains("<redoc"));
196
+ assert!(redoc_html.text().contains("/openapi.json"));
197
+ }
198
+
199
+ async fn assert_static_files(server: &axum_test::TestServer) {
200
+ let static_index = server.get("/static/").await;
201
+ assert_eq!(static_index.status_code(), StatusCode::OK);
202
+ assert!(static_index.text().contains("static index"));
203
+ assert_eq!(
204
+ static_index.header("cache-control").to_str().expect("cache-control"),
205
+ "public, max-age=60"
206
+ );
207
+
208
+ let static_file = server.get("/static/hello.txt").await;
209
+ assert_eq!(static_file.status_code(), StatusCode::OK);
210
+ assert_eq!(static_file.text(), "hello world");
211
+ assert_eq!(
212
+ static_file.header("cache-control").to_str().expect("cache-control"),
213
+ "public, max-age=60"
214
+ );
215
+ }
216
+
217
+ async fn assert_jsonrpc_and_http_routes(server: &axum_test::TestServer) {
218
+ let rpc_request = serde_json::json!({
219
+ "jsonrpc": "2.0",
220
+ "method": "spikard.test.echo",
221
+ "params": {"any": "thing"},
222
+ "id": 1
223
+ });
224
+ let rpc_response = server.post("/rpc").json(&rpc_request).await;
225
+ assert_eq!(rpc_response.status_code(), StatusCode::OK);
226
+ let rpc_json: Value = serde_json::from_str(&rpc_response.text()).expect("jsonrpc response");
227
+ assert_eq!(rpc_json["jsonrpc"], "2.0");
228
+ assert_eq!(rpc_json["id"], 1);
229
+ assert_eq!(rpc_json["result"]["path"], "/rpc");
230
+ assert_eq!(rpc_json["result"]["method"], "POST");
231
+
232
+ let ok_get = server.get("/api/items/550e8400-e29b-41d4-a716-446655440000").await;
233
+ assert_eq!(ok_get.status_code(), StatusCode::OK);
234
+ let ok_get_json: Value = serde_json::from_str(&ok_get.text()).expect("get json");
235
+ assert_eq!(ok_get_json["path_params"]["id"], "550e8400-e29b-41d4-a716-446655440000");
236
+
237
+ let ok_post = server
238
+ .post("/api/items/550e8400-e29b-41d4-a716-446655440000")
239
+ .json(&serde_json::json!({"name": "spikard"}))
240
+ .await;
241
+ assert_eq!(ok_post.status_code(), StatusCode::OK);
242
+ let ok_post_json: Value = serde_json::from_str(&ok_post.text()).expect("post json");
243
+ assert_eq!(ok_post_json["raw_body_json"]["name"], "spikard");
244
+ }
245
+
246
+ #[tokio::test]
247
+ async fn router_supports_openapi_jsonrpc_and_static_files_in_one_config() {
248
+ let dir = tempfile::tempdir().expect("tempdir");
249
+ std::fs::write(dir.path().join("index.html"), "<h1>static index</h1>").expect("write index.html");
250
+ std::fs::write(dir.path().join("hello.txt"), "hello world").expect("write hello.txt");
251
+
252
+ let api_items_path = api_items_path();
253
+ let route_entries = build_routes(&api_items_path);
254
+ let route_metadata = build_route_metadata(&api_items_path);
255
+ let config = build_config(dir.path());
256
+
257
+ let app_router = build_router_with_handlers_and_config(route_entries, config, route_metadata).expect("router");
258
+ let server = axum_test::TestServer::new(app_router).expect("test server");
259
+
260
+ assert_openapi_docs_and_redoc(&server).await;
261
+ assert_static_files(&server).await;
262
+ assert_jsonrpc_and_http_routes(&server).await;
263
+ }
264
+
265
+ #[test]
266
+ fn router_returns_error_for_invalid_cache_control_header_value() {
267
+ let routes: Vec<(Route, Arc<dyn Handler>)> = Vec::new();
268
+
269
+ let config = ServerConfig {
270
+ static_files: vec![StaticFilesConfig {
271
+ directory: "/tmp".to_string(),
272
+ route_prefix: "/static".to_string(),
273
+ index_file: true,
274
+ cache_control: Some("\n".to_string()),
275
+ }],
276
+ ..Default::default()
277
+ };
278
+
279
+ let err = build_router_with_handlers_and_config(routes, config, Vec::new()).expect_err("invalid header");
280
+ assert!(err.contains("Invalid cache-control header"));
281
+ }
@@ -0,0 +1,121 @@
1
+ use axum::body::Body;
2
+ use http_body_util::BodyExt;
3
+ use spikard_http::handler_trait::{Handler, HandlerResult, RequestData};
4
+ use spikard_http::server::build_router_with_handlers;
5
+ use spikard_http::{Method, Route};
6
+ use std::pin::Pin;
7
+ use std::sync::{Arc, Mutex};
8
+ use tower::ServiceExt;
9
+
10
+ #[cfg(feature = "di")]
11
+ fn build_app(routes: Vec<(Route, Arc<dyn Handler>)>) -> axum::Router {
12
+ build_router_with_handlers(routes, None, None).unwrap()
13
+ }
14
+
15
+ #[cfg(not(feature = "di"))]
16
+ fn build_app(routes: Vec<(Route, Arc<dyn Handler>)>) -> axum::Router {
17
+ build_router_with_handlers(routes, None).unwrap()
18
+ }
19
+
20
+ struct CaptureHandler {
21
+ tx: Mutex<Option<tokio::sync::oneshot::Sender<RequestData>>>,
22
+ }
23
+
24
+ impl CaptureHandler {
25
+ const fn new(tx: tokio::sync::oneshot::Sender<RequestData>) -> Self {
26
+ Self {
27
+ tx: Mutex::new(Some(tx)),
28
+ }
29
+ }
30
+ }
31
+
32
+ impl Handler for CaptureHandler {
33
+ fn call(
34
+ &self,
35
+ _request: axum::http::Request<Body>,
36
+ request_data: RequestData,
37
+ ) -> Pin<Box<dyn std::future::Future<Output = HandlerResult> + Send + '_>> {
38
+ Box::pin(async move {
39
+ let maybe_tx = self.tx.lock().expect("lock").take();
40
+ if let Some(tx) = maybe_tx {
41
+ let _ = tx.send(request_data);
42
+ }
43
+ Ok(axum::http::Response::builder().status(200).body(Body::empty()).unwrap())
44
+ })
45
+ }
46
+ }
47
+
48
+ fn route(path: &str, method: Method) -> Route {
49
+ Route {
50
+ path: path.to_string(),
51
+ method,
52
+ handler_name: "capture".to_string(),
53
+ expects_json_body: true,
54
+ cors: None,
55
+ is_async: true,
56
+ file_params: None,
57
+ request_validator: None,
58
+ response_validator: None,
59
+ parameter_validator: None,
60
+ jsonrpc_method: None,
61
+ #[cfg(feature = "di")]
62
+ handler_dependencies: vec![],
63
+ }
64
+ }
65
+
66
+ #[tokio::test]
67
+ async fn post_route_with_path_params_extracts_raw_body_and_path_params() {
68
+ let (tx, rx) = tokio::sync::oneshot::channel();
69
+ let handler: Arc<dyn Handler> = Arc::new(CaptureHandler::new(tx));
70
+
71
+ let path = ["/items/", "{", "id:int", "}"].concat();
72
+ let app = build_app(vec![(route(&path, Method::Post), handler)]);
73
+
74
+ let response = app
75
+ .oneshot(
76
+ axum::http::Request::builder()
77
+ .method("POST")
78
+ .uri("/items/123")
79
+ .header("content-type", "application/json")
80
+ .body(Body::from(r#"{"ok":true}"#))
81
+ .unwrap(),
82
+ )
83
+ .await
84
+ .unwrap();
85
+
86
+ assert_eq!(response.status(), 200);
87
+ let _ = response.into_body().collect().await.unwrap();
88
+
89
+ let captured = rx.await.expect("handler should send request_data");
90
+ assert_eq!(captured.method, "POST");
91
+ assert_eq!(captured.path, "/items/123");
92
+ assert_eq!(captured.path_params.get("id").map(String::as_str), Some("123"));
93
+ assert_eq!(captured.raw_body.as_deref(), Some(&br#"{"ok":true}"#[..]));
94
+ }
95
+
96
+ #[tokio::test]
97
+ async fn get_route_without_body_does_not_set_raw_body() {
98
+ let (tx, rx) = tokio::sync::oneshot::channel();
99
+ let handler: Arc<dyn Handler> = Arc::new(CaptureHandler::new(tx));
100
+
101
+ let app = build_app(vec![(route("/health", Method::Get), handler)]);
102
+
103
+ let response = app
104
+ .oneshot(
105
+ axum::http::Request::builder()
106
+ .method("GET")
107
+ .uri("/health?x=1")
108
+ .body(Body::empty())
109
+ .unwrap(),
110
+ )
111
+ .await
112
+ .unwrap();
113
+
114
+ assert_eq!(response.status(), 200);
115
+ let _ = response.into_body().collect().await.unwrap();
116
+
117
+ let captured = rx.await.expect("handler should send request_data");
118
+ assert_eq!(captured.method, "GET");
119
+ assert_eq!(captured.path, "/health");
120
+ assert!(captured.raw_body.is_none());
121
+ }