spikard 0.3.2 → 0.3.3
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.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +659 -659
- data/ext/spikard_rb/Cargo.toml +17 -17
- data/ext/spikard_rb/extconf.rb +10 -10
- data/ext/spikard_rb/src/lib.rs +6 -6
- data/lib/spikard/app.rb +386 -386
- data/lib/spikard/background.rb +27 -27
- data/lib/spikard/config.rb +396 -396
- data/lib/spikard/converters.rb +13 -13
- data/lib/spikard/handler_wrapper.rb +113 -113
- data/lib/spikard/provide.rb +214 -214
- data/lib/spikard/response.rb +173 -173
- data/lib/spikard/schema.rb +243 -243
- data/lib/spikard/sse.rb +111 -111
- data/lib/spikard/streaming_response.rb +44 -44
- data/lib/spikard/testing.rb +221 -221
- data/lib/spikard/upload_file.rb +131 -131
- data/lib/spikard/version.rb +5 -5
- data/lib/spikard/websocket.rb +59 -59
- data/lib/spikard.rb +43 -43
- data/sig/spikard.rbs +360 -360
- data/vendor/crates/spikard-core/Cargo.toml +40 -40
- data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -3
- data/vendor/crates/spikard-core/src/bindings/response.rs +133 -133
- data/vendor/crates/spikard-core/src/debug.rs +63 -63
- data/vendor/crates/spikard-core/src/di/container.rs +726 -726
- data/vendor/crates/spikard-core/src/di/dependency.rs +273 -273
- data/vendor/crates/spikard-core/src/di/error.rs +118 -118
- data/vendor/crates/spikard-core/src/di/factory.rs +538 -538
- data/vendor/crates/spikard-core/src/di/graph.rs +545 -545
- data/vendor/crates/spikard-core/src/di/mod.rs +192 -192
- data/vendor/crates/spikard-core/src/di/resolved.rs +411 -411
- data/vendor/crates/spikard-core/src/di/value.rs +283 -283
- data/vendor/crates/spikard-core/src/errors.rs +39 -39
- data/vendor/crates/spikard-core/src/http.rs +153 -153
- data/vendor/crates/spikard-core/src/lib.rs +29 -29
- data/vendor/crates/spikard-core/src/lifecycle.rs +422 -422
- data/vendor/crates/spikard-core/src/parameters.rs +722 -722
- data/vendor/crates/spikard-core/src/problem.rs +310 -310
- data/vendor/crates/spikard-core/src/request_data.rs +189 -189
- data/vendor/crates/spikard-core/src/router.rs +249 -249
- data/vendor/crates/spikard-core/src/schema_registry.rs +183 -183
- data/vendor/crates/spikard-core/src/type_hints.rs +304 -304
- data/vendor/crates/spikard-core/src/validation.rs +699 -699
- data/vendor/crates/spikard-http/Cargo.toml +58 -58
- data/vendor/crates/spikard-http/src/auth.rs +247 -247
- data/vendor/crates/spikard-http/src/background.rs +249 -249
- data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -3
- data/vendor/crates/spikard-http/src/bindings/response.rs +1 -1
- data/vendor/crates/spikard-http/src/body_metadata.rs +8 -8
- data/vendor/crates/spikard-http/src/cors.rs +490 -490
- data/vendor/crates/spikard-http/src/debug.rs +63 -63
- data/vendor/crates/spikard-http/src/di_handler.rs +423 -423
- data/vendor/crates/spikard-http/src/handler_response.rs +190 -190
- data/vendor/crates/spikard-http/src/handler_trait.rs +228 -228
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -284
- data/vendor/crates/spikard-http/src/lib.rs +529 -529
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -149
- data/vendor/crates/spikard-http/src/lifecycle.rs +428 -428
- data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -285
- data/vendor/crates/spikard-http/src/middleware/multipart.rs +86 -86
- data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +147 -147
- data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -287
- data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -309
- data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +190 -190
- data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +308 -308
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +195 -195
- data/vendor/crates/spikard-http/src/parameters.rs +1 -1
- data/vendor/crates/spikard-http/src/problem.rs +1 -1
- data/vendor/crates/spikard-http/src/query_parser.rs +369 -369
- data/vendor/crates/spikard-http/src/response.rs +399 -399
- data/vendor/crates/spikard-http/src/router.rs +1 -1
- data/vendor/crates/spikard-http/src/schema_registry.rs +1 -1
- data/vendor/crates/spikard-http/src/server/handler.rs +87 -87
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -98
- data/vendor/crates/spikard-http/src/server/mod.rs +805 -805
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +119 -119
- data/vendor/crates/spikard-http/src/sse.rs +447 -447
- data/vendor/crates/spikard-http/src/testing/form.rs +14 -14
- data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -60
- data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -285
- data/vendor/crates/spikard-http/src/testing.rs +377 -377
- data/vendor/crates/spikard-http/src/type_hints.rs +1 -1
- data/vendor/crates/spikard-http/src/validation.rs +1 -1
- data/vendor/crates/spikard-http/src/websocket.rs +324 -324
- data/vendor/crates/spikard-rb/Cargo.toml +42 -42
- data/vendor/crates/spikard-rb/build.rs +8 -8
- data/vendor/crates/spikard-rb/src/background.rs +63 -63
- data/vendor/crates/spikard-rb/src/config.rs +294 -294
- data/vendor/crates/spikard-rb/src/conversion.rs +453 -453
- data/vendor/crates/spikard-rb/src/di.rs +409 -409
- data/vendor/crates/spikard-rb/src/handler.rs +625 -625
- data/vendor/crates/spikard-rb/src/lib.rs +2771 -2771
- data/vendor/crates/spikard-rb/src/lifecycle.rs +274 -274
- data/vendor/crates/spikard-rb/src/server.rs +283 -283
- data/vendor/crates/spikard-rb/src/sse.rs +231 -231
- data/vendor/crates/spikard-rb/src/test_client.rs +404 -404
- data/vendor/crates/spikard-rb/src/test_sse.rs +143 -143
- data/vendor/crates/spikard-rb/src/test_websocket.rs +221 -221
- data/vendor/crates/spikard-rb/src/websocket.rs +233 -233
- data/vendor/spikard-core/Cargo.toml +40 -40
- data/vendor/spikard-core/src/bindings/mod.rs +3 -3
- data/vendor/spikard-core/src/bindings/response.rs +133 -133
- data/vendor/spikard-core/src/debug.rs +63 -63
- data/vendor/spikard-core/src/di/container.rs +726 -726
- data/vendor/spikard-core/src/di/dependency.rs +273 -273
- data/vendor/spikard-core/src/di/error.rs +118 -118
- data/vendor/spikard-core/src/di/factory.rs +538 -538
- data/vendor/spikard-core/src/di/graph.rs +545 -545
- data/vendor/spikard-core/src/di/mod.rs +192 -192
- data/vendor/spikard-core/src/di/resolved.rs +411 -411
- data/vendor/spikard-core/src/di/value.rs +283 -283
- data/vendor/spikard-core/src/http.rs +153 -153
- data/vendor/spikard-core/src/lib.rs +28 -28
- data/vendor/spikard-core/src/lifecycle.rs +422 -422
- data/vendor/spikard-core/src/parameters.rs +719 -719
- data/vendor/spikard-core/src/problem.rs +310 -310
- data/vendor/spikard-core/src/request_data.rs +189 -189
- data/vendor/spikard-core/src/router.rs +249 -249
- data/vendor/spikard-core/src/schema_registry.rs +183 -183
- data/vendor/spikard-core/src/type_hints.rs +304 -304
- data/vendor/spikard-core/src/validation.rs +699 -699
- data/vendor/spikard-http/Cargo.toml +58 -58
- data/vendor/spikard-http/src/auth.rs +247 -247
- data/vendor/spikard-http/src/background.rs +249 -249
- data/vendor/spikard-http/src/bindings/mod.rs +3 -3
- data/vendor/spikard-http/src/bindings/response.rs +1 -1
- data/vendor/spikard-http/src/body_metadata.rs +8 -8
- data/vendor/spikard-http/src/cors.rs +490 -490
- data/vendor/spikard-http/src/debug.rs +63 -63
- data/vendor/spikard-http/src/di_handler.rs +423 -423
- data/vendor/spikard-http/src/handler_response.rs +190 -190
- data/vendor/spikard-http/src/handler_trait.rs +228 -228
- data/vendor/spikard-http/src/handler_trait_tests.rs +284 -284
- data/vendor/spikard-http/src/lib.rs +529 -529
- data/vendor/spikard-http/src/lifecycle/adapter.rs +149 -149
- data/vendor/spikard-http/src/lifecycle.rs +428 -428
- data/vendor/spikard-http/src/middleware/mod.rs +285 -285
- data/vendor/spikard-http/src/middleware/multipart.rs +86 -86
- data/vendor/spikard-http/src/middleware/urlencoded.rs +147 -147
- data/vendor/spikard-http/src/middleware/validation.rs +287 -287
- data/vendor/spikard-http/src/openapi/mod.rs +309 -309
- data/vendor/spikard-http/src/openapi/parameter_extraction.rs +190 -190
- data/vendor/spikard-http/src/openapi/schema_conversion.rs +308 -308
- data/vendor/spikard-http/src/openapi/spec_generation.rs +195 -195
- data/vendor/spikard-http/src/parameters.rs +1 -1
- data/vendor/spikard-http/src/problem.rs +1 -1
- data/vendor/spikard-http/src/query_parser.rs +369 -369
- data/vendor/spikard-http/src/response.rs +399 -399
- data/vendor/spikard-http/src/router.rs +1 -1
- data/vendor/spikard-http/src/schema_registry.rs +1 -1
- data/vendor/spikard-http/src/server/handler.rs +80 -80
- data/vendor/spikard-http/src/server/lifecycle_execution.rs +98 -98
- data/vendor/spikard-http/src/server/mod.rs +805 -805
- data/vendor/spikard-http/src/server/request_extraction.rs +119 -119
- data/vendor/spikard-http/src/sse.rs +447 -447
- data/vendor/spikard-http/src/testing/form.rs +14 -14
- data/vendor/spikard-http/src/testing/multipart.rs +60 -60
- data/vendor/spikard-http/src/testing/test_client.rs +285 -285
- data/vendor/spikard-http/src/testing.rs +377 -377
- data/vendor/spikard-http/src/type_hints.rs +1 -1
- data/vendor/spikard-http/src/validation.rs +1 -1
- data/vendor/spikard-http/src/websocket.rs +324 -324
- data/vendor/spikard-rb/Cargo.toml +42 -42
- data/vendor/spikard-rb/build.rs +8 -8
- data/vendor/spikard-rb/src/background.rs +63 -63
- data/vendor/spikard-rb/src/config.rs +294 -294
- data/vendor/spikard-rb/src/conversion.rs +392 -392
- data/vendor/spikard-rb/src/di.rs +409 -409
- data/vendor/spikard-rb/src/handler.rs +534 -534
- data/vendor/spikard-rb/src/lib.rs +2020 -2020
- data/vendor/spikard-rb/src/lifecycle.rs +267 -267
- data/vendor/spikard-rb/src/server.rs +283 -283
- data/vendor/spikard-rb/src/sse.rs +231 -231
- data/vendor/spikard-rb/src/test_client.rs +404 -404
- data/vendor/spikard-rb/src/test_sse.rs +143 -143
- data/vendor/spikard-rb/src/test_websocket.rs +221 -221
- data/vendor/spikard-rb/src/websocket.rs +233 -233
- metadata +1 -1
|
@@ -1,285 +1,285 @@
|
|
|
1
|
-
//! Core test client for Spikard applications
|
|
2
|
-
//!
|
|
3
|
-
//! This module provides a language-agnostic TestClient that can be wrapped by
|
|
4
|
-
//! language bindings (PyO3, napi-rs, magnus) to provide Pythonic, JavaScripty, and
|
|
5
|
-
//! Ruby-like APIs respectively.
|
|
6
|
-
//!
|
|
7
|
-
//! The core client handles all HTTP method dispatch, query params, header management,
|
|
8
|
-
//! body encoding (JSON, form-data, multipart), and response snapshot capture.
|
|
9
|
-
|
|
10
|
-
use super::{ResponseSnapshot, SnapshotError, snapshot_response};
|
|
11
|
-
use axum::http::{HeaderName, HeaderValue, Method};
|
|
12
|
-
use axum_test::TestServer;
|
|
13
|
-
use bytes::Bytes;
|
|
14
|
-
use serde_json::Value;
|
|
15
|
-
use std::sync::Arc;
|
|
16
|
-
use urlencoding::encode;
|
|
17
|
-
|
|
18
|
-
type MultipartPayload = Option<(Vec<(String, String)>, Vec<super::MultipartFilePart>)>;
|
|
19
|
-
|
|
20
|
-
/// Core test client for making HTTP requests to a Spikard application.
|
|
21
|
-
///
|
|
22
|
-
/// This struct wraps axum-test's TestServer and provides a language-agnostic
|
|
23
|
-
/// interface for making HTTP requests, sending WebSocket connections, and
|
|
24
|
-
/// handling Server-Sent Events. Language bindings wrap this to provide
|
|
25
|
-
/// native API surfaces.
|
|
26
|
-
pub struct TestClient {
|
|
27
|
-
server: Arc<TestServer>,
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
impl TestClient {
|
|
31
|
-
/// Create a new test client from an Axum router
|
|
32
|
-
pub fn from_router(router: axum::Router) -> Result<Self, String> {
|
|
33
|
-
let server = TestServer::new(router).map_err(|e| format!("Failed to create test server: {}", e))?;
|
|
34
|
-
|
|
35
|
-
Ok(Self {
|
|
36
|
-
server: Arc::new(server),
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/// Get the underlying test server (for WebSocket and SSE connections)
|
|
41
|
-
pub fn server(&self) -> &TestServer {
|
|
42
|
-
&self.server
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/// Make a GET request
|
|
46
|
-
pub async fn get(
|
|
47
|
-
&self,
|
|
48
|
-
path: &str,
|
|
49
|
-
query_params: Option<Vec<(String, String)>>,
|
|
50
|
-
headers: Option<Vec<(String, String)>>,
|
|
51
|
-
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
52
|
-
let full_path = build_full_path(path, query_params.as_deref());
|
|
53
|
-
let mut request = self.server.get(&full_path);
|
|
54
|
-
|
|
55
|
-
if let Some(headers_vec) = headers {
|
|
56
|
-
request = self.add_headers(request, headers_vec)?;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
let response = request.await;
|
|
60
|
-
snapshot_response(response).await
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/// Make a POST request
|
|
64
|
-
pub async fn post(
|
|
65
|
-
&self,
|
|
66
|
-
path: &str,
|
|
67
|
-
json: Option<Value>,
|
|
68
|
-
form_data: Option<Vec<(String, String)>>,
|
|
69
|
-
multipart: MultipartPayload,
|
|
70
|
-
query_params: Option<Vec<(String, String)>>,
|
|
71
|
-
headers: Option<Vec<(String, String)>>,
|
|
72
|
-
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
73
|
-
let full_path = build_full_path(path, query_params.as_deref());
|
|
74
|
-
let mut request = self.server.post(&full_path);
|
|
75
|
-
|
|
76
|
-
// Apply headers first
|
|
77
|
-
if let Some(headers_vec) = headers {
|
|
78
|
-
request = self.add_headers(request, headers_vec.clone())?;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Determine body and content-type
|
|
82
|
-
if let Some((form_fields, files)) = multipart {
|
|
83
|
-
let (body, boundary) = super::build_multipart_body(&form_fields, &files);
|
|
84
|
-
let content_type = format!("multipart/form-data; boundary={}", boundary);
|
|
85
|
-
request = request.add_header("content-type", &content_type);
|
|
86
|
-
request = request.bytes(Bytes::from(body));
|
|
87
|
-
} else if let Some(form_fields) = form_data {
|
|
88
|
-
let encoded = super::encode_urlencoded_body(&serde_json::to_value(&form_fields).unwrap_or(Value::Null))
|
|
89
|
-
.map_err(|e| SnapshotError::Decompression(format!("Form encoding failed: {}", e)))?;
|
|
90
|
-
request = request.add_header("content-type", "application/x-www-form-urlencoded");
|
|
91
|
-
request = request.bytes(Bytes::from(encoded));
|
|
92
|
-
} else if let Some(json_value) = json {
|
|
93
|
-
request = request.json(&json_value);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
let response = request.await;
|
|
97
|
-
snapshot_response(response).await
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/// Make a PUT request
|
|
101
|
-
pub async fn put(
|
|
102
|
-
&self,
|
|
103
|
-
path: &str,
|
|
104
|
-
json: Option<Value>,
|
|
105
|
-
query_params: Option<Vec<(String, String)>>,
|
|
106
|
-
headers: Option<Vec<(String, String)>>,
|
|
107
|
-
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
108
|
-
let full_path = build_full_path(path, query_params.as_deref());
|
|
109
|
-
let mut request = self.server.put(&full_path);
|
|
110
|
-
|
|
111
|
-
if let Some(headers_vec) = headers {
|
|
112
|
-
request = self.add_headers(request, headers_vec)?;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if let Some(json_value) = json {
|
|
116
|
-
request = request.json(&json_value);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
let response = request.await;
|
|
120
|
-
snapshot_response(response).await
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/// Make a PATCH request
|
|
124
|
-
pub async fn patch(
|
|
125
|
-
&self,
|
|
126
|
-
path: &str,
|
|
127
|
-
json: Option<Value>,
|
|
128
|
-
query_params: Option<Vec<(String, String)>>,
|
|
129
|
-
headers: Option<Vec<(String, String)>>,
|
|
130
|
-
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
131
|
-
let full_path = build_full_path(path, query_params.as_deref());
|
|
132
|
-
let mut request = self.server.patch(&full_path);
|
|
133
|
-
|
|
134
|
-
if let Some(headers_vec) = headers {
|
|
135
|
-
request = self.add_headers(request, headers_vec)?;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if let Some(json_value) = json {
|
|
139
|
-
request = request.json(&json_value);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
let response = request.await;
|
|
143
|
-
snapshot_response(response).await
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/// Make a DELETE request
|
|
147
|
-
pub async fn delete(
|
|
148
|
-
&self,
|
|
149
|
-
path: &str,
|
|
150
|
-
query_params: Option<Vec<(String, String)>>,
|
|
151
|
-
headers: Option<Vec<(String, String)>>,
|
|
152
|
-
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
153
|
-
let full_path = build_full_path(path, query_params.as_deref());
|
|
154
|
-
let mut request = self.server.delete(&full_path);
|
|
155
|
-
|
|
156
|
-
if let Some(headers_vec) = headers {
|
|
157
|
-
request = self.add_headers(request, headers_vec)?;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
let response = request.await;
|
|
161
|
-
snapshot_response(response).await
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/// Make an OPTIONS request
|
|
165
|
-
pub async fn options(
|
|
166
|
-
&self,
|
|
167
|
-
path: &str,
|
|
168
|
-
query_params: Option<Vec<(String, String)>>,
|
|
169
|
-
headers: Option<Vec<(String, String)>>,
|
|
170
|
-
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
171
|
-
let full_path = build_full_path(path, query_params.as_deref());
|
|
172
|
-
let mut request = self.server.method(Method::OPTIONS, &full_path);
|
|
173
|
-
|
|
174
|
-
if let Some(headers_vec) = headers {
|
|
175
|
-
request = self.add_headers(request, headers_vec)?;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
let response = request.await;
|
|
179
|
-
snapshot_response(response).await
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/// Make a HEAD request
|
|
183
|
-
pub async fn head(
|
|
184
|
-
&self,
|
|
185
|
-
path: &str,
|
|
186
|
-
query_params: Option<Vec<(String, String)>>,
|
|
187
|
-
headers: Option<Vec<(String, String)>>,
|
|
188
|
-
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
189
|
-
let full_path = build_full_path(path, query_params.as_deref());
|
|
190
|
-
let mut request = self.server.method(Method::HEAD, &full_path);
|
|
191
|
-
|
|
192
|
-
if let Some(headers_vec) = headers {
|
|
193
|
-
request = self.add_headers(request, headers_vec)?;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
let response = request.await;
|
|
197
|
-
snapshot_response(response).await
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/// Make a TRACE request
|
|
201
|
-
pub async fn trace(
|
|
202
|
-
&self,
|
|
203
|
-
path: &str,
|
|
204
|
-
query_params: Option<Vec<(String, String)>>,
|
|
205
|
-
headers: Option<Vec<(String, String)>>,
|
|
206
|
-
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
207
|
-
let full_path = build_full_path(path, query_params.as_deref());
|
|
208
|
-
let mut request = self.server.method(Method::TRACE, &full_path);
|
|
209
|
-
|
|
210
|
-
if let Some(headers_vec) = headers {
|
|
211
|
-
request = self.add_headers(request, headers_vec)?;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
let response = request.await;
|
|
215
|
-
snapshot_response(response).await
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/// Add headers to a test request builder
|
|
219
|
-
fn add_headers(
|
|
220
|
-
&self,
|
|
221
|
-
mut request: axum_test::TestRequest,
|
|
222
|
-
headers: Vec<(String, String)>,
|
|
223
|
-
) -> Result<axum_test::TestRequest, SnapshotError> {
|
|
224
|
-
for (key, value) in headers {
|
|
225
|
-
let header_name = HeaderName::from_bytes(key.as_bytes())
|
|
226
|
-
.map_err(|e| SnapshotError::InvalidHeader(format!("Invalid header name: {}", e)))?;
|
|
227
|
-
let header_value = HeaderValue::from_str(&value)
|
|
228
|
-
.map_err(|e| SnapshotError::InvalidHeader(format!("Invalid header value: {}", e)))?;
|
|
229
|
-
request = request.add_header(header_name, header_value);
|
|
230
|
-
}
|
|
231
|
-
Ok(request)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/// Build a full path with query parameters
|
|
236
|
-
fn build_full_path(path: &str, query_params: Option<&[(String, String)]>) -> String {
|
|
237
|
-
match query_params {
|
|
238
|
-
None | Some(&[]) => path.to_string(),
|
|
239
|
-
Some(params) => {
|
|
240
|
-
let query_string: Vec<String> = params
|
|
241
|
-
.iter()
|
|
242
|
-
.map(|(k, v)| format!("{}={}", encode(k), encode(v)))
|
|
243
|
-
.collect();
|
|
244
|
-
|
|
245
|
-
if path.contains('?') {
|
|
246
|
-
format!("{}&{}", path, query_string.join("&"))
|
|
247
|
-
} else {
|
|
248
|
-
format!("{}?{}", path, query_string.join("&"))
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
#[cfg(test)]
|
|
255
|
-
mod tests {
|
|
256
|
-
use super::*;
|
|
257
|
-
|
|
258
|
-
#[test]
|
|
259
|
-
fn build_full_path_no_params() {
|
|
260
|
-
let path = "/users";
|
|
261
|
-
assert_eq!(build_full_path(path, None), "/users");
|
|
262
|
-
assert_eq!(build_full_path(path, Some(&[])), "/users");
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
#[test]
|
|
266
|
-
fn build_full_path_with_params() {
|
|
267
|
-
let path = "/users";
|
|
268
|
-
let params = vec![
|
|
269
|
-
("id".to_string(), "123".to_string()),
|
|
270
|
-
("name".to_string(), "test user".to_string()),
|
|
271
|
-
];
|
|
272
|
-
let result = build_full_path(path, Some(¶ms));
|
|
273
|
-
assert!(result.starts_with("/users?"));
|
|
274
|
-
assert!(result.contains("id=123"));
|
|
275
|
-
assert!(result.contains("name=test%20user"));
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
#[test]
|
|
279
|
-
fn build_full_path_existing_query() {
|
|
280
|
-
let path = "/users?active=true";
|
|
281
|
-
let params = vec![("id".to_string(), "123".to_string())];
|
|
282
|
-
let result = build_full_path(path, Some(¶ms));
|
|
283
|
-
assert_eq!(result, "/users?active=true&id=123");
|
|
284
|
-
}
|
|
285
|
-
}
|
|
1
|
+
//! Core test client for Spikard applications
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides a language-agnostic TestClient that can be wrapped by
|
|
4
|
+
//! language bindings (PyO3, napi-rs, magnus) to provide Pythonic, JavaScripty, and
|
|
5
|
+
//! Ruby-like APIs respectively.
|
|
6
|
+
//!
|
|
7
|
+
//! The core client handles all HTTP method dispatch, query params, header management,
|
|
8
|
+
//! body encoding (JSON, form-data, multipart), and response snapshot capture.
|
|
9
|
+
|
|
10
|
+
use super::{ResponseSnapshot, SnapshotError, snapshot_response};
|
|
11
|
+
use axum::http::{HeaderName, HeaderValue, Method};
|
|
12
|
+
use axum_test::TestServer;
|
|
13
|
+
use bytes::Bytes;
|
|
14
|
+
use serde_json::Value;
|
|
15
|
+
use std::sync::Arc;
|
|
16
|
+
use urlencoding::encode;
|
|
17
|
+
|
|
18
|
+
type MultipartPayload = Option<(Vec<(String, String)>, Vec<super::MultipartFilePart>)>;
|
|
19
|
+
|
|
20
|
+
/// Core test client for making HTTP requests to a Spikard application.
|
|
21
|
+
///
|
|
22
|
+
/// This struct wraps axum-test's TestServer and provides a language-agnostic
|
|
23
|
+
/// interface for making HTTP requests, sending WebSocket connections, and
|
|
24
|
+
/// handling Server-Sent Events. Language bindings wrap this to provide
|
|
25
|
+
/// native API surfaces.
|
|
26
|
+
pub struct TestClient {
|
|
27
|
+
server: Arc<TestServer>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl TestClient {
|
|
31
|
+
/// Create a new test client from an Axum router
|
|
32
|
+
pub fn from_router(router: axum::Router) -> Result<Self, String> {
|
|
33
|
+
let server = TestServer::new(router).map_err(|e| format!("Failed to create test server: {}", e))?;
|
|
34
|
+
|
|
35
|
+
Ok(Self {
|
|
36
|
+
server: Arc::new(server),
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Get the underlying test server (for WebSocket and SSE connections)
|
|
41
|
+
pub fn server(&self) -> &TestServer {
|
|
42
|
+
&self.server
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Make a GET request
|
|
46
|
+
pub async fn get(
|
|
47
|
+
&self,
|
|
48
|
+
path: &str,
|
|
49
|
+
query_params: Option<Vec<(String, String)>>,
|
|
50
|
+
headers: Option<Vec<(String, String)>>,
|
|
51
|
+
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
52
|
+
let full_path = build_full_path(path, query_params.as_deref());
|
|
53
|
+
let mut request = self.server.get(&full_path);
|
|
54
|
+
|
|
55
|
+
if let Some(headers_vec) = headers {
|
|
56
|
+
request = self.add_headers(request, headers_vec)?;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let response = request.await;
|
|
60
|
+
snapshot_response(response).await
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Make a POST request
|
|
64
|
+
pub async fn post(
|
|
65
|
+
&self,
|
|
66
|
+
path: &str,
|
|
67
|
+
json: Option<Value>,
|
|
68
|
+
form_data: Option<Vec<(String, String)>>,
|
|
69
|
+
multipart: MultipartPayload,
|
|
70
|
+
query_params: Option<Vec<(String, String)>>,
|
|
71
|
+
headers: Option<Vec<(String, String)>>,
|
|
72
|
+
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
73
|
+
let full_path = build_full_path(path, query_params.as_deref());
|
|
74
|
+
let mut request = self.server.post(&full_path);
|
|
75
|
+
|
|
76
|
+
// Apply headers first
|
|
77
|
+
if let Some(headers_vec) = headers {
|
|
78
|
+
request = self.add_headers(request, headers_vec.clone())?;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Determine body and content-type
|
|
82
|
+
if let Some((form_fields, files)) = multipart {
|
|
83
|
+
let (body, boundary) = super::build_multipart_body(&form_fields, &files);
|
|
84
|
+
let content_type = format!("multipart/form-data; boundary={}", boundary);
|
|
85
|
+
request = request.add_header("content-type", &content_type);
|
|
86
|
+
request = request.bytes(Bytes::from(body));
|
|
87
|
+
} else if let Some(form_fields) = form_data {
|
|
88
|
+
let encoded = super::encode_urlencoded_body(&serde_json::to_value(&form_fields).unwrap_or(Value::Null))
|
|
89
|
+
.map_err(|e| SnapshotError::Decompression(format!("Form encoding failed: {}", e)))?;
|
|
90
|
+
request = request.add_header("content-type", "application/x-www-form-urlencoded");
|
|
91
|
+
request = request.bytes(Bytes::from(encoded));
|
|
92
|
+
} else if let Some(json_value) = json {
|
|
93
|
+
request = request.json(&json_value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let response = request.await;
|
|
97
|
+
snapshot_response(response).await
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Make a PUT request
|
|
101
|
+
pub async fn put(
|
|
102
|
+
&self,
|
|
103
|
+
path: &str,
|
|
104
|
+
json: Option<Value>,
|
|
105
|
+
query_params: Option<Vec<(String, String)>>,
|
|
106
|
+
headers: Option<Vec<(String, String)>>,
|
|
107
|
+
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
108
|
+
let full_path = build_full_path(path, query_params.as_deref());
|
|
109
|
+
let mut request = self.server.put(&full_path);
|
|
110
|
+
|
|
111
|
+
if let Some(headers_vec) = headers {
|
|
112
|
+
request = self.add_headers(request, headers_vec)?;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if let Some(json_value) = json {
|
|
116
|
+
request = request.json(&json_value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let response = request.await;
|
|
120
|
+
snapshot_response(response).await
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Make a PATCH request
|
|
124
|
+
pub async fn patch(
|
|
125
|
+
&self,
|
|
126
|
+
path: &str,
|
|
127
|
+
json: Option<Value>,
|
|
128
|
+
query_params: Option<Vec<(String, String)>>,
|
|
129
|
+
headers: Option<Vec<(String, String)>>,
|
|
130
|
+
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
131
|
+
let full_path = build_full_path(path, query_params.as_deref());
|
|
132
|
+
let mut request = self.server.patch(&full_path);
|
|
133
|
+
|
|
134
|
+
if let Some(headers_vec) = headers {
|
|
135
|
+
request = self.add_headers(request, headers_vec)?;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if let Some(json_value) = json {
|
|
139
|
+
request = request.json(&json_value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let response = request.await;
|
|
143
|
+
snapshot_response(response).await
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Make a DELETE request
|
|
147
|
+
pub async fn delete(
|
|
148
|
+
&self,
|
|
149
|
+
path: &str,
|
|
150
|
+
query_params: Option<Vec<(String, String)>>,
|
|
151
|
+
headers: Option<Vec<(String, String)>>,
|
|
152
|
+
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
153
|
+
let full_path = build_full_path(path, query_params.as_deref());
|
|
154
|
+
let mut request = self.server.delete(&full_path);
|
|
155
|
+
|
|
156
|
+
if let Some(headers_vec) = headers {
|
|
157
|
+
request = self.add_headers(request, headers_vec)?;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let response = request.await;
|
|
161
|
+
snapshot_response(response).await
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Make an OPTIONS request
|
|
165
|
+
pub async fn options(
|
|
166
|
+
&self,
|
|
167
|
+
path: &str,
|
|
168
|
+
query_params: Option<Vec<(String, String)>>,
|
|
169
|
+
headers: Option<Vec<(String, String)>>,
|
|
170
|
+
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
171
|
+
let full_path = build_full_path(path, query_params.as_deref());
|
|
172
|
+
let mut request = self.server.method(Method::OPTIONS, &full_path);
|
|
173
|
+
|
|
174
|
+
if let Some(headers_vec) = headers {
|
|
175
|
+
request = self.add_headers(request, headers_vec)?;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let response = request.await;
|
|
179
|
+
snapshot_response(response).await
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// Make a HEAD request
|
|
183
|
+
pub async fn head(
|
|
184
|
+
&self,
|
|
185
|
+
path: &str,
|
|
186
|
+
query_params: Option<Vec<(String, String)>>,
|
|
187
|
+
headers: Option<Vec<(String, String)>>,
|
|
188
|
+
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
189
|
+
let full_path = build_full_path(path, query_params.as_deref());
|
|
190
|
+
let mut request = self.server.method(Method::HEAD, &full_path);
|
|
191
|
+
|
|
192
|
+
if let Some(headers_vec) = headers {
|
|
193
|
+
request = self.add_headers(request, headers_vec)?;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let response = request.await;
|
|
197
|
+
snapshot_response(response).await
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// Make a TRACE request
|
|
201
|
+
pub async fn trace(
|
|
202
|
+
&self,
|
|
203
|
+
path: &str,
|
|
204
|
+
query_params: Option<Vec<(String, String)>>,
|
|
205
|
+
headers: Option<Vec<(String, String)>>,
|
|
206
|
+
) -> Result<ResponseSnapshot, SnapshotError> {
|
|
207
|
+
let full_path = build_full_path(path, query_params.as_deref());
|
|
208
|
+
let mut request = self.server.method(Method::TRACE, &full_path);
|
|
209
|
+
|
|
210
|
+
if let Some(headers_vec) = headers {
|
|
211
|
+
request = self.add_headers(request, headers_vec)?;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let response = request.await;
|
|
215
|
+
snapshot_response(response).await
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Add headers to a test request builder
|
|
219
|
+
fn add_headers(
|
|
220
|
+
&self,
|
|
221
|
+
mut request: axum_test::TestRequest,
|
|
222
|
+
headers: Vec<(String, String)>,
|
|
223
|
+
) -> Result<axum_test::TestRequest, SnapshotError> {
|
|
224
|
+
for (key, value) in headers {
|
|
225
|
+
let header_name = HeaderName::from_bytes(key.as_bytes())
|
|
226
|
+
.map_err(|e| SnapshotError::InvalidHeader(format!("Invalid header name: {}", e)))?;
|
|
227
|
+
let header_value = HeaderValue::from_str(&value)
|
|
228
|
+
.map_err(|e| SnapshotError::InvalidHeader(format!("Invalid header value: {}", e)))?;
|
|
229
|
+
request = request.add_header(header_name, header_value);
|
|
230
|
+
}
|
|
231
|
+
Ok(request)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// Build a full path with query parameters
|
|
236
|
+
fn build_full_path(path: &str, query_params: Option<&[(String, String)]>) -> String {
|
|
237
|
+
match query_params {
|
|
238
|
+
None | Some(&[]) => path.to_string(),
|
|
239
|
+
Some(params) => {
|
|
240
|
+
let query_string: Vec<String> = params
|
|
241
|
+
.iter()
|
|
242
|
+
.map(|(k, v)| format!("{}={}", encode(k), encode(v)))
|
|
243
|
+
.collect();
|
|
244
|
+
|
|
245
|
+
if path.contains('?') {
|
|
246
|
+
format!("{}&{}", path, query_string.join("&"))
|
|
247
|
+
} else {
|
|
248
|
+
format!("{}?{}", path, query_string.join("&"))
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[cfg(test)]
|
|
255
|
+
mod tests {
|
|
256
|
+
use super::*;
|
|
257
|
+
|
|
258
|
+
#[test]
|
|
259
|
+
fn build_full_path_no_params() {
|
|
260
|
+
let path = "/users";
|
|
261
|
+
assert_eq!(build_full_path(path, None), "/users");
|
|
262
|
+
assert_eq!(build_full_path(path, Some(&[])), "/users");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[test]
|
|
266
|
+
fn build_full_path_with_params() {
|
|
267
|
+
let path = "/users";
|
|
268
|
+
let params = vec![
|
|
269
|
+
("id".to_string(), "123".to_string()),
|
|
270
|
+
("name".to_string(), "test user".to_string()),
|
|
271
|
+
];
|
|
272
|
+
let result = build_full_path(path, Some(¶ms));
|
|
273
|
+
assert!(result.starts_with("/users?"));
|
|
274
|
+
assert!(result.contains("id=123"));
|
|
275
|
+
assert!(result.contains("name=test%20user"));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#[test]
|
|
279
|
+
fn build_full_path_existing_query() {
|
|
280
|
+
let path = "/users?active=true";
|
|
281
|
+
let params = vec![("id".to_string(), "123".to_string())];
|
|
282
|
+
let result = build_full_path(path, Some(¶ms));
|
|
283
|
+
assert_eq!(result, "/users?active=true&id=123");
|
|
284
|
+
}
|
|
285
|
+
}
|