spikard 0.4.0-x64-mingw-ucrt

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 (138) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +1 -0
  3. data/README.md +659 -0
  4. data/ext/spikard_rb/Cargo.toml +17 -0
  5. data/ext/spikard_rb/extconf.rb +10 -0
  6. data/ext/spikard_rb/src/lib.rs +6 -0
  7. data/lib/spikard/app.rb +405 -0
  8. data/lib/spikard/background.rb +27 -0
  9. data/lib/spikard/config.rb +396 -0
  10. data/lib/spikard/converters.rb +13 -0
  11. data/lib/spikard/handler_wrapper.rb +113 -0
  12. data/lib/spikard/provide.rb +214 -0
  13. data/lib/spikard/response.rb +173 -0
  14. data/lib/spikard/schema.rb +243 -0
  15. data/lib/spikard/sse.rb +111 -0
  16. data/lib/spikard/streaming_response.rb +44 -0
  17. data/lib/spikard/testing.rb +221 -0
  18. data/lib/spikard/upload_file.rb +131 -0
  19. data/lib/spikard/version.rb +5 -0
  20. data/lib/spikard/websocket.rb +59 -0
  21. data/lib/spikard.rb +43 -0
  22. data/sig/spikard.rbs +366 -0
  23. data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +5 -0
  24. data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +2 -0
  25. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
  26. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +139 -0
  27. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +561 -0
  28. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
  29. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
  30. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +403 -0
  31. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +274 -0
  32. data/vendor/crates/spikard-bindings-shared/src/lib.rs +25 -0
  33. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +298 -0
  34. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +637 -0
  35. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +309 -0
  36. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
  37. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +355 -0
  38. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +502 -0
  39. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +389 -0
  40. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +413 -0
  41. data/vendor/crates/spikard-core/Cargo.toml +40 -0
  42. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
  43. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
  44. data/vendor/crates/spikard-core/src/debug.rs +63 -0
  45. data/vendor/crates/spikard-core/src/di/container.rs +726 -0
  46. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
  47. data/vendor/crates/spikard-core/src/di/error.rs +118 -0
  48. data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
  49. data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
  50. data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
  51. data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
  52. data/vendor/crates/spikard-core/src/di/value.rs +283 -0
  53. data/vendor/crates/spikard-core/src/errors.rs +39 -0
  54. data/vendor/crates/spikard-core/src/http.rs +153 -0
  55. data/vendor/crates/spikard-core/src/lib.rs +29 -0
  56. data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
  57. data/vendor/crates/spikard-core/src/metadata.rs +397 -0
  58. data/vendor/crates/spikard-core/src/parameters.rs +723 -0
  59. data/vendor/crates/spikard-core/src/problem.rs +310 -0
  60. data/vendor/crates/spikard-core/src/request_data.rs +189 -0
  61. data/vendor/crates/spikard-core/src/router.rs +249 -0
  62. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
  63. data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
  64. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +689 -0
  65. data/vendor/crates/spikard-core/src/validation/mod.rs +459 -0
  66. data/vendor/crates/spikard-http/Cargo.toml +58 -0
  67. data/vendor/crates/spikard-http/examples/sse-notifications.rs +147 -0
  68. data/vendor/crates/spikard-http/examples/websocket-chat.rs +91 -0
  69. data/vendor/crates/spikard-http/src/auth.rs +247 -0
  70. data/vendor/crates/spikard-http/src/background.rs +1562 -0
  71. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
  72. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
  73. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
  74. data/vendor/crates/spikard-http/src/cors.rs +490 -0
  75. data/vendor/crates/spikard-http/src/debug.rs +63 -0
  76. data/vendor/crates/spikard-http/src/di_handler.rs +1878 -0
  77. data/vendor/crates/spikard-http/src/handler_response.rs +532 -0
  78. data/vendor/crates/spikard-http/src/handler_trait.rs +861 -0
  79. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
  80. data/vendor/crates/spikard-http/src/lib.rs +524 -0
  81. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
  82. data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
  83. data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
  84. data/vendor/crates/spikard-http/src/middleware/multipart.rs +930 -0
  85. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +541 -0
  86. data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
  87. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
  88. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -0
  89. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +867 -0
  90. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +678 -0
  91. data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
  92. data/vendor/crates/spikard-http/src/response.rs +399 -0
  93. data/vendor/crates/spikard-http/src/server/handler.rs +1557 -0
  94. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
  95. data/vendor/crates/spikard-http/src/server/mod.rs +806 -0
  96. data/vendor/crates/spikard-http/src/server/request_extraction.rs +630 -0
  97. data/vendor/crates/spikard-http/src/server/routing_factory.rs +497 -0
  98. data/vendor/crates/spikard-http/src/sse.rs +961 -0
  99. data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
  100. data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
  101. data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
  102. data/vendor/crates/spikard-http/src/testing.rs +377 -0
  103. data/vendor/crates/spikard-http/src/websocket.rs +831 -0
  104. data/vendor/crates/spikard-http/tests/background_behavior.rs +918 -0
  105. data/vendor/crates/spikard-http/tests/common/handlers.rs +308 -0
  106. data/vendor/crates/spikard-http/tests/common/mod.rs +21 -0
  107. data/vendor/crates/spikard-http/tests/di_integration.rs +202 -0
  108. data/vendor/crates/spikard-http/tests/doc_snippets.rs +4 -0
  109. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1135 -0
  110. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +688 -0
  111. data/vendor/crates/spikard-http/tests/server_config_builder.rs +324 -0
  112. data/vendor/crates/spikard-http/tests/sse_behavior.rs +728 -0
  113. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +724 -0
  114. data/vendor/crates/spikard-rb/Cargo.toml +43 -0
  115. data/vendor/crates/spikard-rb/build.rs +199 -0
  116. data/vendor/crates/spikard-rb/src/background.rs +63 -0
  117. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
  118. data/vendor/crates/spikard-rb/src/config/server_config.rs +283 -0
  119. data/vendor/crates/spikard-rb/src/conversion.rs +459 -0
  120. data/vendor/crates/spikard-rb/src/di/builder.rs +105 -0
  121. data/vendor/crates/spikard-rb/src/di/mod.rs +413 -0
  122. data/vendor/crates/spikard-rb/src/handler.rs +612 -0
  123. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
  124. data/vendor/crates/spikard-rb/src/lib.rs +1857 -0
  125. data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -0
  126. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
  127. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +427 -0
  128. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
  129. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +326 -0
  130. data/vendor/crates/spikard-rb/src/server.rs +283 -0
  131. data/vendor/crates/spikard-rb/src/sse.rs +231 -0
  132. data/vendor/crates/spikard-rb/src/testing/client.rs +404 -0
  133. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
  134. data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -0
  135. data/vendor/crates/spikard-rb/src/testing/websocket.rs +221 -0
  136. data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
  137. data/vendor/crates/spikard-rb/tests/magnus_ffi_tests.rs +14 -0
  138. metadata +213 -0
@@ -0,0 +1,14 @@
1
+ use serde_json::Value;
2
+
3
+ /// Encode JSON form data as application/x-www-form-urlencoded bytes.
4
+ pub fn encode_urlencoded_body(value: &Value) -> Result<Vec<u8>, String> {
5
+ match value {
6
+ Value::String(s) => Ok(s.as_bytes().to_vec()),
7
+ Value::Null => Ok(Vec::new()),
8
+ Value::Bool(b) => Ok(b.to_string().into_bytes()),
9
+ Value::Number(num) => Ok(num.to_string().into_bytes()),
10
+ Value::Object(_) | Value::Array(_) => serde_qs::to_string(value)
11
+ .map(|encoded| encoded.into_bytes())
12
+ .map_err(|err| err.to_string()),
13
+ }
14
+ }
@@ -0,0 +1,60 @@
1
+ use std::time::{SystemTime, UNIX_EPOCH};
2
+
3
+ /// File part metadata for multipart/form-data payloads.
4
+ #[derive(Debug, Clone)]
5
+ pub struct MultipartFilePart {
6
+ pub field_name: String,
7
+ pub filename: String,
8
+ pub content_type: Option<String>,
9
+ pub content: Vec<u8>,
10
+ }
11
+
12
+ /// Build a multipart/form-data body from fields and files.
13
+ pub fn build_multipart_body(form_fields: &[(String, String)], files: &[MultipartFilePart]) -> (Vec<u8>, String) {
14
+ let boundary = generate_boundary();
15
+ let mut body = Vec::new();
16
+
17
+ for (name, value) in form_fields {
18
+ body.extend_from_slice(b"--");
19
+ body.extend_from_slice(boundary.as_bytes());
20
+ body.extend_from_slice(b"\r\n");
21
+ body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
22
+ body.extend_from_slice(name.as_bytes());
23
+ body.extend_from_slice(b"\"\r\n\r\n");
24
+ body.extend_from_slice(value.as_bytes());
25
+ body.extend_from_slice(b"\r\n");
26
+ }
27
+
28
+ for file in files {
29
+ body.extend_from_slice(b"--");
30
+ body.extend_from_slice(boundary.as_bytes());
31
+ body.extend_from_slice(b"\r\n");
32
+ body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
33
+ body.extend_from_slice(file.field_name.as_bytes());
34
+ body.extend_from_slice(b"\"; filename=\"");
35
+ body.extend_from_slice(file.filename.as_bytes());
36
+ body.extend_from_slice(b"\"\r\n");
37
+ if let Some(content_type) = &file.content_type {
38
+ body.extend_from_slice(b"Content-Type: ");
39
+ body.extend_from_slice(content_type.as_bytes());
40
+ body.extend_from_slice(b"\r\n");
41
+ }
42
+ body.extend_from_slice(b"\r\n");
43
+ body.extend_from_slice(&file.content);
44
+ body.extend_from_slice(b"\r\n");
45
+ }
46
+
47
+ body.extend_from_slice(b"--");
48
+ body.extend_from_slice(boundary.as_bytes());
49
+ body.extend_from_slice(b"--\r\n");
50
+
51
+ (body, boundary)
52
+ }
53
+
54
+ fn generate_boundary() -> String {
55
+ let nanos = SystemTime::now()
56
+ .duration_since(UNIX_EPOCH)
57
+ .map(|duration| duration.as_nanos())
58
+ .unwrap_or_default();
59
+ format!("spikard-boundary-{nanos}")
60
+ }
@@ -0,0 +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(&params));
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(&params));
283
+ assert_eq!(result, "/users?active=true&id=123");
284
+ }
285
+ }