spikard 0.8.3 → 0.10.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.
- checksums.yaml +4 -4
- data/README.md +19 -10
- data/ext/spikard_rb/Cargo.lock +234 -162
- data/ext/spikard_rb/Cargo.toml +2 -2
- data/ext/spikard_rb/extconf.rb +4 -3
- data/lib/spikard/config.rb +88 -12
- data/lib/spikard/testing.rb +3 -1
- data/lib/spikard/version.rb +1 -1
- data/lib/spikard.rb +11 -0
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +3 -6
- data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +8 -8
- data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +2 -2
- data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +4 -4
- data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +10 -4
- data/vendor/crates/spikard-bindings-shared/src/error_response.rs +3 -3
- data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +10 -5
- data/vendor/crates/spikard-bindings-shared/src/json_conversion.rs +829 -0
- data/vendor/crates/spikard-bindings-shared/src/lazy_cache.rs +587 -0
- data/vendor/crates/spikard-bindings-shared/src/lib.rs +7 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +11 -11
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +9 -37
- data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +436 -3
- data/vendor/crates/spikard-bindings-shared/src/response_interpreter.rs +944 -0
- data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +4 -4
- data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +3 -2
- data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +13 -13
- data/vendor/crates/spikard-bindings-shared/tests/{comprehensive_coverage.rs → full_coverage.rs} +10 -5
- data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +14 -14
- data/vendor/crates/spikard-bindings-shared/tests/integration_tests.rs +669 -0
- data/vendor/crates/spikard-core/Cargo.toml +3 -3
- data/vendor/crates/spikard-core/src/di/container.rs +1 -1
- data/vendor/crates/spikard-core/src/di/factory.rs +2 -2
- data/vendor/crates/spikard-core/src/di/resolved.rs +2 -2
- data/vendor/crates/spikard-core/src/di/value.rs +1 -1
- data/vendor/crates/spikard-core/src/http.rs +75 -0
- data/vendor/crates/spikard-core/src/lifecycle.rs +43 -43
- data/vendor/crates/spikard-core/src/parameters.rs +14 -19
- data/vendor/crates/spikard-core/src/problem.rs +1 -1
- data/vendor/crates/spikard-core/src/request_data.rs +7 -16
- data/vendor/crates/spikard-core/src/router.rs +6 -0
- data/vendor/crates/spikard-core/src/schema_registry.rs +2 -3
- data/vendor/crates/spikard-core/src/type_hints.rs +3 -2
- data/vendor/crates/spikard-core/src/validation/error_mapper.rs +1 -1
- data/vendor/crates/spikard-core/src/validation/mod.rs +1 -1
- data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +1 -1
- data/vendor/crates/spikard-core/tests/error_mapper.rs +2 -2
- data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +1 -1
- data/vendor/crates/spikard-core/tests/parameters_full.rs +1 -1
- data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +1 -1
- data/vendor/crates/spikard-core/tests/validation_coverage.rs +4 -4
- data/vendor/crates/spikard-http/Cargo.toml +4 -2
- data/vendor/crates/spikard-http/src/cors.rs +32 -11
- data/vendor/crates/spikard-http/src/di_handler.rs +12 -8
- data/vendor/crates/spikard-http/src/grpc/framing.rs +469 -0
- data/vendor/crates/spikard-http/src/grpc/handler.rs +887 -25
- data/vendor/crates/spikard-http/src/grpc/mod.rs +114 -22
- data/vendor/crates/spikard-http/src/grpc/service.rs +232 -2
- data/vendor/crates/spikard-http/src/grpc/streaming.rs +80 -2
- data/vendor/crates/spikard-http/src/handler_trait.rs +204 -27
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +15 -15
- data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +2 -2
- data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2 -2
- data/vendor/crates/spikard-http/src/lib.rs +1 -1
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +2 -2
- data/vendor/crates/spikard-http/src/lifecycle.rs +4 -4
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +2 -0
- data/vendor/crates/spikard-http/src/server/fast_router.rs +186 -0
- data/vendor/crates/spikard-http/src/server/grpc_routing.rs +324 -23
- data/vendor/crates/spikard-http/src/server/handler.rs +33 -22
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +21 -2
- data/vendor/crates/spikard-http/src/server/mod.rs +125 -20
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +126 -44
- data/vendor/crates/spikard-http/src/server/routing_factory.rs +80 -69
- data/vendor/crates/spikard-http/tests/common/handlers.rs +2 -2
- data/vendor/crates/spikard-http/tests/common/test_builders.rs +12 -12
- data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +2 -2
- data/vendor/crates/spikard-http/tests/di_integration.rs +6 -6
- data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +430 -0
- data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +738 -0
- data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +13 -9
- data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +974 -0
- data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +2 -2
- data/vendor/crates/spikard-http/tests/request_extraction_full.rs +4 -4
- data/vendor/crates/spikard-http/tests/server_config_builder.rs +2 -2
- data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +1 -0
- data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +140 -0
- data/vendor/crates/spikard-rb/Cargo.toml +3 -1
- data/vendor/crates/spikard-rb/src/conversion.rs +138 -4
- data/vendor/crates/spikard-rb/src/grpc/handler.rs +706 -229
- data/vendor/crates/spikard-rb/src/grpc/mod.rs +6 -2
- data/vendor/crates/spikard-rb/src/gvl.rs +2 -2
- data/vendor/crates/spikard-rb/src/handler.rs +169 -91
- data/vendor/crates/spikard-rb/src/lib.rs +444 -62
- data/vendor/crates/spikard-rb/src/lifecycle.rs +29 -1
- data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +108 -43
- data/vendor/crates/spikard-rb/src/request.rs +117 -20
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +52 -25
- data/vendor/crates/spikard-rb/src/server.rs +23 -14
- data/vendor/crates/spikard-rb/src/testing/client.rs +5 -4
- data/vendor/crates/spikard-rb/src/testing/sse.rs +1 -36
- data/vendor/crates/spikard-rb/src/testing/websocket.rs +3 -38
- data/vendor/crates/spikard-rb/src/websocket.rs +32 -23
- data/vendor/crates/spikard-rb-macros/Cargo.toml +1 -1
- metadata +14 -4
- data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +0 -5
- data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +0 -2
|
@@ -26,10 +26,10 @@ use std::sync::Arc;
|
|
|
26
26
|
fn test_request_data() -> RequestData {
|
|
27
27
|
RequestData {
|
|
28
28
|
path_params: Arc::new(HashMap::new()),
|
|
29
|
-
query_params: serde_json::Value::Null,
|
|
29
|
+
query_params: Arc::new(serde_json::Value::Null),
|
|
30
30
|
validated_params: None,
|
|
31
31
|
raw_query_params: Arc::new(HashMap::new()),
|
|
32
|
-
body: json!({"test": "data"}),
|
|
32
|
+
body: Arc::new(json!({"test": "data"})),
|
|
33
33
|
raw_body: None,
|
|
34
34
|
headers: Arc::new(HashMap::new()),
|
|
35
35
|
cookies: Arc::new(HashMap::new()),
|
|
@@ -393,7 +393,7 @@ fn test_body_json_stored() {
|
|
|
393
393
|
.json_body(body_json.clone())
|
|
394
394
|
.build();
|
|
395
395
|
|
|
396
|
-
assert_eq!(request_data.body, body_json);
|
|
396
|
+
assert_eq!(*request_data.body, body_json);
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
/// Test empty body handling
|
|
@@ -404,7 +404,7 @@ fn test_body_json_stored() {
|
|
|
404
404
|
fn test_body_empty() {
|
|
405
405
|
let (_request, request_data) = RequestBuilder::new().method(Method::GET).path("/status").build();
|
|
406
406
|
|
|
407
|
-
assert_eq!(request_data.body, json!(null));
|
|
407
|
+
assert_eq!(*request_data.body, json!(null));
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
/// Test large JSON body
|
|
@@ -427,7 +427,7 @@ fn test_body_large_json() {
|
|
|
427
427
|
.json_body(large_body.clone())
|
|
428
428
|
.build();
|
|
429
429
|
|
|
430
|
-
assert_eq!(request_data.body, large_body);
|
|
430
|
+
assert_eq!(*request_data.body, large_body);
|
|
431
431
|
}
|
|
432
432
|
|
|
433
433
|
/// Test complete request with path, query, headers, cookies, and body
|
|
@@ -464,7 +464,7 @@ fn test_complete_request_with_all_components() {
|
|
|
464
464
|
assert_eq!(request_data.cookies.get("session"), Some(&"xyz789".to_string()));
|
|
465
465
|
assert_eq!(request_data.cookies.get("preferences"), Some(&"dark_mode".to_string()));
|
|
466
466
|
|
|
467
|
-
assert_eq!(request_data.body, body);
|
|
467
|
+
assert_eq!(*request_data.body, body);
|
|
468
468
|
}
|
|
469
469
|
|
|
470
470
|
/// Test Arc wrapping for efficient cloning
|
|
@@ -246,10 +246,10 @@ mod common_handler_tests {
|
|
|
246
246
|
fn create_test_request_data() -> RequestData {
|
|
247
247
|
RequestData {
|
|
248
248
|
path_params: Arc::new(HashMap::new()),
|
|
249
|
-
query_params: serde_json::Value::Null,
|
|
249
|
+
query_params: Arc::new(serde_json::Value::Null),
|
|
250
250
|
validated_params: None,
|
|
251
251
|
raw_query_params: Arc::new(HashMap::new()),
|
|
252
|
-
body: json!({"test": "data"}),
|
|
252
|
+
body: Arc::new(json!({"test": "data"})),
|
|
253
253
|
raw_body: None,
|
|
254
254
|
headers: Arc::new(HashMap::new()),
|
|
255
255
|
cookies: Arc::new(HashMap::new()),
|
|
@@ -135,6 +135,7 @@ fn build_route_metadata(path: &str) -> Vec<RouteMetadata> {
|
|
|
135
135
|
})
|
|
136
136
|
.expect("jsonrpc method info"),
|
|
137
137
|
),
|
|
138
|
+
static_response: None,
|
|
138
139
|
},
|
|
139
140
|
RouteMetadata {
|
|
140
141
|
method: "POST".to_string(),
|
|
@@ -150,6 +151,7 @@ fn build_route_metadata(path: &str) -> Vec<RouteMetadata> {
|
|
|
150
151
|
#[cfg(feature = "di")]
|
|
151
152
|
handler_dependencies: None,
|
|
152
153
|
jsonrpc_method: None,
|
|
154
|
+
static_response: None,
|
|
153
155
|
},
|
|
154
156
|
]
|
|
155
157
|
}
|
|
@@ -279,3 +281,141 @@ fn router_returns_error_for_invalid_cache_control_header_value() {
|
|
|
279
281
|
let err = build_router_with_handlers_and_config(routes, config, Vec::new()).expect_err("invalid header");
|
|
280
282
|
assert!(err.contains("Invalid cache-control header"));
|
|
281
283
|
}
|
|
284
|
+
|
|
285
|
+
/// Verify that a route registered with `StaticResponseHandler` serves the
|
|
286
|
+
/// pre-built response and that the handler's `call()` is never invoked.
|
|
287
|
+
#[tokio::test]
|
|
288
|
+
async fn static_response_route_serves_pre_built_response() {
|
|
289
|
+
use spikard_http::{StaticResponse, StaticResponseHandler};
|
|
290
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
291
|
+
|
|
292
|
+
// A handler that tracks whether call() was invoked.
|
|
293
|
+
static HANDLER_CALLED: AtomicBool = AtomicBool::new(false);
|
|
294
|
+
struct SpyHandler {
|
|
295
|
+
inner: StaticResponseHandler,
|
|
296
|
+
}
|
|
297
|
+
impl Handler for SpyHandler {
|
|
298
|
+
fn call(
|
|
299
|
+
&self,
|
|
300
|
+
req: axum::http::Request<Body>,
|
|
301
|
+
data: RequestData,
|
|
302
|
+
) -> Pin<Box<dyn Future<Output = spikard_http::HandlerResult> + Send + '_>> {
|
|
303
|
+
HANDLER_CALLED.store(true, Ordering::SeqCst);
|
|
304
|
+
self.inner.call(req, data)
|
|
305
|
+
}
|
|
306
|
+
fn static_response(&self) -> Option<StaticResponse> {
|
|
307
|
+
self.inner.static_response()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
HANDLER_CALLED.store(false, Ordering::SeqCst);
|
|
312
|
+
|
|
313
|
+
let handler = SpyHandler {
|
|
314
|
+
inner: StaticResponseHandler::from_parts(200, r#"{"status":"healthy"}"#, Some("application/json"), vec![]),
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
let route_meta = RouteMetadata {
|
|
318
|
+
method: "GET".to_string(),
|
|
319
|
+
path: "/health".to_string(),
|
|
320
|
+
handler_name: "health_check".to_string(),
|
|
321
|
+
request_schema: None,
|
|
322
|
+
response_schema: None,
|
|
323
|
+
parameter_schema: None,
|
|
324
|
+
file_params: None,
|
|
325
|
+
is_async: false,
|
|
326
|
+
cors: None,
|
|
327
|
+
body_param_name: None,
|
|
328
|
+
#[cfg(feature = "di")]
|
|
329
|
+
handler_dependencies: None,
|
|
330
|
+
jsonrpc_method: None,
|
|
331
|
+
static_response: None,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
let route = spikard_http::Route::from_metadata(route_meta.clone(), &spikard_http::SchemaRegistry::new())
|
|
335
|
+
.expect("route creation");
|
|
336
|
+
|
|
337
|
+
let routes: Vec<(spikard_http::Route, Arc<dyn Handler>)> = vec![(route, Arc::new(handler) as Arc<dyn Handler>)];
|
|
338
|
+
|
|
339
|
+
let config = ServerConfig::default();
|
|
340
|
+
let app = build_router_with_handlers_and_config(routes, config, vec![route_meta]).expect("router");
|
|
341
|
+
let server = axum_test::TestServer::new(app).expect("test server");
|
|
342
|
+
|
|
343
|
+
let resp = server.get("/health").await;
|
|
344
|
+
assert_eq!(resp.status_code(), StatusCode::OK);
|
|
345
|
+
assert_eq!(resp.text(), r#"{"status":"healthy"}"#);
|
|
346
|
+
assert_eq!(resp.header("content-type").to_str().unwrap(), "application/json");
|
|
347
|
+
// The static fast-path should have served the response without calling the handler.
|
|
348
|
+
assert!(
|
|
349
|
+
!HANDLER_CALLED.load(Ordering::SeqCst),
|
|
350
|
+
"Handler.call() should not be invoked for static response routes"
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/// Verify that a static route coexists with dynamic routes on the same server.
|
|
355
|
+
#[tokio::test]
|
|
356
|
+
async fn static_and_dynamic_routes_coexist() {
|
|
357
|
+
use spikard_http::StaticResponseHandler;
|
|
358
|
+
|
|
359
|
+
let static_handler = StaticResponseHandler::from_parts(200, "OK", None, vec![]);
|
|
360
|
+
let echo_handler = EchoHandler;
|
|
361
|
+
|
|
362
|
+
let api_items_path = api_items_path();
|
|
363
|
+
|
|
364
|
+
let static_meta = RouteMetadata {
|
|
365
|
+
method: "GET".to_string(),
|
|
366
|
+
path: "/health".to_string(),
|
|
367
|
+
handler_name: "health".to_string(),
|
|
368
|
+
request_schema: None,
|
|
369
|
+
response_schema: None,
|
|
370
|
+
parameter_schema: None,
|
|
371
|
+
file_params: None,
|
|
372
|
+
is_async: false,
|
|
373
|
+
cors: None,
|
|
374
|
+
body_param_name: None,
|
|
375
|
+
#[cfg(feature = "di")]
|
|
376
|
+
handler_dependencies: None,
|
|
377
|
+
jsonrpc_method: None,
|
|
378
|
+
static_response: None,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
let dynamic_meta = RouteMetadata {
|
|
382
|
+
method: "GET".to_string(),
|
|
383
|
+
path: api_items_path.clone(),
|
|
384
|
+
handler_name: "echo_get".to_string(),
|
|
385
|
+
request_schema: None,
|
|
386
|
+
response_schema: None,
|
|
387
|
+
parameter_schema: None,
|
|
388
|
+
file_params: None,
|
|
389
|
+
is_async: true,
|
|
390
|
+
cors: None,
|
|
391
|
+
body_param_name: None,
|
|
392
|
+
#[cfg(feature = "di")]
|
|
393
|
+
handler_dependencies: None,
|
|
394
|
+
jsonrpc_method: None,
|
|
395
|
+
static_response: None,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
let registry = spikard_http::SchemaRegistry::new();
|
|
399
|
+
let static_route = spikard_http::Route::from_metadata(static_meta.clone(), ®istry).expect("static route");
|
|
400
|
+
let dynamic_route = spikard_http::Route::from_metadata(dynamic_meta.clone(), ®istry).expect("dynamic route");
|
|
401
|
+
|
|
402
|
+
let routes: Vec<(spikard_http::Route, Arc<dyn Handler>)> = vec![
|
|
403
|
+
(static_route, Arc::new(static_handler) as Arc<dyn Handler>),
|
|
404
|
+
(dynamic_route, Arc::new(echo_handler) as Arc<dyn Handler>),
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
let config = ServerConfig::default();
|
|
408
|
+
let app = build_router_with_handlers_and_config(routes, config, vec![static_meta, dynamic_meta]).expect("router");
|
|
409
|
+
let server = axum_test::TestServer::new(app).expect("test server");
|
|
410
|
+
|
|
411
|
+
// Static route
|
|
412
|
+
let resp = server.get("/health").await;
|
|
413
|
+
assert_eq!(resp.status_code(), StatusCode::OK);
|
|
414
|
+
assert_eq!(resp.text(), "OK");
|
|
415
|
+
|
|
416
|
+
// Dynamic route still works
|
|
417
|
+
let resp = server.get("/api/items/550e8400-e29b-41d4-a716-446655440000").await;
|
|
418
|
+
assert_eq!(resp.status_code(), StatusCode::OK);
|
|
419
|
+
let json: serde_json::Value = serde_json::from_str(&resp.text()).expect("json");
|
|
420
|
+
assert_eq!(json["path_params"]["id"], "550e8400-e29b-41d4-a716-446655440000");
|
|
421
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "spikard-rb"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.2"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -44,6 +44,8 @@ serde_qs = "0.15"
|
|
|
44
44
|
urlencoding = "2.1"
|
|
45
45
|
once_cell = "1.21"
|
|
46
46
|
async-stream = "0.3"
|
|
47
|
+
futures-util = "0.3"
|
|
48
|
+
futures = "0.3"
|
|
47
49
|
http = "1.4"
|
|
48
50
|
paste = "1.0.15"
|
|
49
51
|
rb-sys = "0"
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
//!
|
|
3
3
|
//! This module provides functions for converting between Ruby and Rust types,
|
|
4
4
|
//! including JSON conversion, string conversion, and request/response building.
|
|
5
|
+
//!
|
|
6
|
+
//! The module implements the `JsonConverter` trait from `spikard-bindings-shared`
|
|
7
|
+
//! to eliminate code duplication across bindings while preserving Ruby-specific features
|
|
8
|
+
//! like lazy caching and upload file handling.
|
|
5
9
|
|
|
6
10
|
#![allow(dead_code)]
|
|
7
11
|
#![deny(clippy::unwrap_used)]
|
|
@@ -10,12 +14,51 @@ use bytes::Bytes;
|
|
|
10
14
|
use magnus::prelude::*;
|
|
11
15
|
use magnus::{Error, RArray, RHash, RString, Ruby, Symbol, TryConvert, Value};
|
|
12
16
|
use serde_json::Value as JsonValue;
|
|
17
|
+
use spikard_bindings_shared::JsonConverter;
|
|
13
18
|
use spikard_core::problem::ProblemDetails;
|
|
14
19
|
use spikard_http::testing::MultipartFilePart;
|
|
15
20
|
use std::collections::HashMap;
|
|
16
21
|
|
|
17
22
|
use crate::testing::client::{RequestBody, RequestConfig, TestResponseData};
|
|
18
23
|
|
|
24
|
+
/// Ruby implementation of the JsonConverter trait
|
|
25
|
+
///
|
|
26
|
+
/// Provides bidirectional conversion between `serde_json::Value` and Ruby `Value`.
|
|
27
|
+
/// Leverages the `JsonConversionHelper` for fast-path optimization on primitives.
|
|
28
|
+
pub struct RubyJsonConverter;
|
|
29
|
+
|
|
30
|
+
impl JsonConverter for RubyJsonConverter {
|
|
31
|
+
type LanguageValue = Value;
|
|
32
|
+
type Error = Error;
|
|
33
|
+
|
|
34
|
+
fn json_to_language(value: &JsonValue) -> Result<Self::LanguageValue, Self::Error> {
|
|
35
|
+
let ruby = Ruby::get().map_err(|err| {
|
|
36
|
+
Error::new(
|
|
37
|
+
magnus::exception::runtime_error(),
|
|
38
|
+
format!("Failed to get Ruby runtime: {err}"),
|
|
39
|
+
)
|
|
40
|
+
})?;
|
|
41
|
+
json_to_ruby(&ruby, value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn language_to_json(value: &Self::LanguageValue) -> Result<JsonValue, Self::Error> {
|
|
45
|
+
let ruby = Ruby::get().map_err(|err| {
|
|
46
|
+
Error::new(
|
|
47
|
+
magnus::exception::runtime_error(),
|
|
48
|
+
format!("Failed to get Ruby runtime: {err}"),
|
|
49
|
+
)
|
|
50
|
+
})?;
|
|
51
|
+
|
|
52
|
+
// Get the JSON module from Ruby
|
|
53
|
+
let json_module = ruby
|
|
54
|
+
.class_object()
|
|
55
|
+
.const_get("JSON")
|
|
56
|
+
.map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
|
|
57
|
+
|
|
58
|
+
ruby_value_to_json(&ruby, json_module, *value)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
19
62
|
/// Convert a Ruby value to JSON.
|
|
20
63
|
///
|
|
21
64
|
/// Fast-path converts common Ruby types directly in Rust to avoid
|
|
@@ -70,7 +113,12 @@ fn ruby_value_to_json_fast(
|
|
|
70
113
|
|
|
71
114
|
if let Ok(text) = RString::try_convert(value) {
|
|
72
115
|
let slice = unsafe { text.as_slice() };
|
|
73
|
-
|
|
116
|
+
// Performance: Use from_utf8() with explicit error handling to avoid unnecessary allocation.
|
|
117
|
+
// from_utf8_lossy() always allocates even for valid UTF-8, while from_utf8() returns
|
|
118
|
+
// a zero-copy reference when the bytes are valid UTF-8 (which is the common case).
|
|
119
|
+
let string =
|
|
120
|
+
String::from_utf8(slice.to_vec()).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
|
|
121
|
+
return Ok(Some(JsonValue::String(string)));
|
|
74
122
|
}
|
|
75
123
|
|
|
76
124
|
if value.is_kind_of(ruby.class_float()) {
|
|
@@ -111,7 +159,10 @@ fn ruby_value_to_json_fast(
|
|
|
111
159
|
hash.foreach(|key: Value, val: Value| {
|
|
112
160
|
let key_str = if let Ok(key_text) = RString::try_convert(key) {
|
|
113
161
|
let slice = unsafe { key_text.as_slice() };
|
|
114
|
-
|
|
162
|
+
// Performance: Avoid unnecessary allocation for valid UTF-8 hash keys.
|
|
163
|
+
// Most Ruby hash keys are valid UTF-8 strings, so from_utf8() is faster
|
|
164
|
+
// than from_utf8_lossy() which always allocates.
|
|
165
|
+
String::from_utf8(slice.to_vec()).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
|
|
115
166
|
} else if let Ok(sym) = Symbol::try_convert(key) {
|
|
116
167
|
sym.name()?.into_owned()
|
|
117
168
|
} else {
|
|
@@ -194,17 +245,25 @@ pub fn json_to_ruby_with_uploads(
|
|
|
194
245
|
json_to_ruby_with_uploads(ruby, item, upload_file_class)?,
|
|
195
246
|
)?;
|
|
196
247
|
}
|
|
248
|
+
apply_indifferent_access(ruby, &hash)?;
|
|
197
249
|
Ok(hash.as_value())
|
|
198
250
|
}
|
|
199
251
|
}
|
|
200
252
|
}
|
|
201
253
|
|
|
254
|
+
fn apply_indifferent_access(ruby: &Ruby, hash: &RHash) -> Result<(), Error> {
|
|
255
|
+
let default_proc = ruby.eval::<Value>("->(h, k) { k.is_a?(Symbol) ? h[k.to_s] : nil }")?;
|
|
256
|
+
let _: Value = hash.funcall("default_proc=", (default_proc,))?;
|
|
257
|
+
Ok(())
|
|
258
|
+
}
|
|
259
|
+
|
|
202
260
|
/// Convert a HashMap to a Ruby Hash.
|
|
203
261
|
pub fn map_to_ruby_hash(ruby: &Ruby, map: &HashMap<String, String>) -> Result<Value, Error> {
|
|
204
262
|
let hash = ruby.hash_new_capa(map.len());
|
|
205
263
|
for (key, value) in map {
|
|
206
264
|
hash.aset(ruby.str_new(key), ruby.str_new(value))?;
|
|
207
265
|
}
|
|
266
|
+
apply_indifferent_access(ruby, &hash)?;
|
|
208
267
|
Ok(hash.as_value())
|
|
209
268
|
}
|
|
210
269
|
|
|
@@ -218,6 +277,7 @@ pub fn multimap_to_ruby_hash(ruby: &Ruby, map: &HashMap<String, Vec<String>>) ->
|
|
|
218
277
|
}
|
|
219
278
|
hash.aset(ruby.str_new(key), array)?;
|
|
220
279
|
}
|
|
280
|
+
apply_indifferent_access(ruby, &hash)?;
|
|
221
281
|
Ok(hash.as_value())
|
|
222
282
|
}
|
|
223
283
|
|
|
@@ -312,18 +372,38 @@ pub fn problem_to_json(problem: &ProblemDetails) -> String {
|
|
|
312
372
|
.unwrap_or_else(|err| format!("Failed to serialise problem details: {err}"))
|
|
313
373
|
}
|
|
314
374
|
|
|
375
|
+
/// Ensure the handler value can be invoked via `call`.
|
|
376
|
+
pub fn ensure_callable(ruby: &Ruby, value: Value, name: &str) -> Result<Value, Error> {
|
|
377
|
+
if value.is_nil() {
|
|
378
|
+
return Err(Error::new(
|
|
379
|
+
ruby.exception_type_error(),
|
|
380
|
+
format!("Handler '{name}' must respond to #call"),
|
|
381
|
+
));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let call_sym = ruby.intern("call");
|
|
385
|
+
if !value.respond_to(call_sym, false)? {
|
|
386
|
+
return Err(Error::new(
|
|
387
|
+
ruby.exception_type_error(),
|
|
388
|
+
format!("Handler '{name}' must respond to #call"),
|
|
389
|
+
));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
Ok(value)
|
|
393
|
+
}
|
|
394
|
+
|
|
315
395
|
/// Fetch a handler from a Ruby Hash by name.
|
|
316
396
|
///
|
|
317
397
|
/// Tries both symbol and string keys.
|
|
318
398
|
pub fn fetch_handler(ruby: &Ruby, handlers: &RHash, name: &str) -> Result<Value, Error> {
|
|
319
399
|
let symbol_key = ruby.intern(name);
|
|
320
400
|
if let Some(value) = handlers.get(symbol_key) {
|
|
321
|
-
return
|
|
401
|
+
return ensure_callable(ruby, value, name);
|
|
322
402
|
}
|
|
323
403
|
|
|
324
404
|
let string_key = ruby.str_new(name);
|
|
325
405
|
if let Some(value) = handlers.get(string_key) {
|
|
326
|
-
return
|
|
406
|
+
return ensure_callable(ruby, value, name);
|
|
327
407
|
}
|
|
328
408
|
|
|
329
409
|
Err(Error::new(
|
|
@@ -552,3 +632,57 @@ pub fn get_required_string_from_hash(hash: RHash, key: &str, ruby: &Ruby) -> Res
|
|
|
552
632
|
}
|
|
553
633
|
String::try_convert(value)
|
|
554
634
|
}
|
|
635
|
+
|
|
636
|
+
#[cfg(test)]
|
|
637
|
+
mod tests {
|
|
638
|
+
use super::*;
|
|
639
|
+
use serde_json::json;
|
|
640
|
+
|
|
641
|
+
/// Test that RubyJsonConverter implements the JsonConverter trait correctly
|
|
642
|
+
#[test]
|
|
643
|
+
fn test_ruby_json_converter_trait_implemented() {
|
|
644
|
+
// This test verifies that RubyJsonConverter can be used where JsonConverter is expected
|
|
645
|
+
// The trait implementation ensures that conversion logic is centralized and reusable
|
|
646
|
+
let _ = RubyJsonConverter;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/// Test the conversion flow matches the existing functions
|
|
650
|
+
/// This validates that the trait implementation is compatible with existing code
|
|
651
|
+
#[test]
|
|
652
|
+
fn test_json_conversion_consistency() {
|
|
653
|
+
// Test a simple JSON value
|
|
654
|
+
let simple_json = json!(42);
|
|
655
|
+
assert_eq!(simple_json, 42);
|
|
656
|
+
|
|
657
|
+
// Test a complex JSON object
|
|
658
|
+
let complex_json = json!({
|
|
659
|
+
"name": "test",
|
|
660
|
+
"count": 42,
|
|
661
|
+
"active": true,
|
|
662
|
+
"items": [1, 2, 3]
|
|
663
|
+
});
|
|
664
|
+
assert!(complex_json.is_object());
|
|
665
|
+
assert_eq!(complex_json["count"], 42);
|
|
666
|
+
assert_eq!(complex_json["active"], true);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/// Test that upload file metadata is properly handled
|
|
670
|
+
#[test]
|
|
671
|
+
fn test_upload_file_structure() {
|
|
672
|
+
let upload_json = json!({
|
|
673
|
+
"filename": "test.txt",
|
|
674
|
+
"content": "test content",
|
|
675
|
+
"content_type": "text/plain",
|
|
676
|
+
"size": 12,
|
|
677
|
+
"headers": {
|
|
678
|
+
"X-Custom": "value"
|
|
679
|
+
},
|
|
680
|
+
"content_encoding": "utf-8"
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
assert_eq!(upload_json["filename"], "test.txt");
|
|
684
|
+
assert_eq!(upload_json["content"], "test content");
|
|
685
|
+
assert_eq!(upload_json["content_type"], "text/plain");
|
|
686
|
+
assert_eq!(upload_json["size"], 12);
|
|
687
|
+
}
|
|
688
|
+
}
|