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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -10
  3. data/ext/spikard_rb/Cargo.lock +234 -162
  4. data/ext/spikard_rb/Cargo.toml +2 -2
  5. data/ext/spikard_rb/extconf.rb +4 -3
  6. data/lib/spikard/config.rb +88 -12
  7. data/lib/spikard/testing.rb +3 -1
  8. data/lib/spikard/version.rb +1 -1
  9. data/lib/spikard.rb +11 -0
  10. data/vendor/crates/spikard-bindings-shared/Cargo.toml +3 -6
  11. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +8 -8
  12. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +2 -2
  13. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +4 -4
  14. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +10 -4
  15. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +3 -3
  16. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +10 -5
  17. data/vendor/crates/spikard-bindings-shared/src/json_conversion.rs +829 -0
  18. data/vendor/crates/spikard-bindings-shared/src/lazy_cache.rs +587 -0
  19. data/vendor/crates/spikard-bindings-shared/src/lib.rs +7 -0
  20. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +11 -11
  21. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +9 -37
  22. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +436 -3
  23. data/vendor/crates/spikard-bindings-shared/src/response_interpreter.rs +944 -0
  24. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +4 -4
  25. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +3 -2
  26. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +13 -13
  27. data/vendor/crates/spikard-bindings-shared/tests/{comprehensive_coverage.rs → full_coverage.rs} +10 -5
  28. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +14 -14
  29. data/vendor/crates/spikard-bindings-shared/tests/integration_tests.rs +669 -0
  30. data/vendor/crates/spikard-core/Cargo.toml +3 -3
  31. data/vendor/crates/spikard-core/src/di/container.rs +1 -1
  32. data/vendor/crates/spikard-core/src/di/factory.rs +2 -2
  33. data/vendor/crates/spikard-core/src/di/resolved.rs +2 -2
  34. data/vendor/crates/spikard-core/src/di/value.rs +1 -1
  35. data/vendor/crates/spikard-core/src/http.rs +75 -0
  36. data/vendor/crates/spikard-core/src/lifecycle.rs +43 -43
  37. data/vendor/crates/spikard-core/src/parameters.rs +14 -19
  38. data/vendor/crates/spikard-core/src/problem.rs +1 -1
  39. data/vendor/crates/spikard-core/src/request_data.rs +7 -16
  40. data/vendor/crates/spikard-core/src/router.rs +6 -0
  41. data/vendor/crates/spikard-core/src/schema_registry.rs +2 -3
  42. data/vendor/crates/spikard-core/src/type_hints.rs +3 -2
  43. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +1 -1
  44. data/vendor/crates/spikard-core/src/validation/mod.rs +1 -1
  45. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +1 -1
  46. data/vendor/crates/spikard-core/tests/error_mapper.rs +2 -2
  47. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +1 -1
  48. data/vendor/crates/spikard-core/tests/parameters_full.rs +1 -1
  49. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +1 -1
  50. data/vendor/crates/spikard-core/tests/validation_coverage.rs +4 -4
  51. data/vendor/crates/spikard-http/Cargo.toml +4 -2
  52. data/vendor/crates/spikard-http/src/cors.rs +32 -11
  53. data/vendor/crates/spikard-http/src/di_handler.rs +12 -8
  54. data/vendor/crates/spikard-http/src/grpc/framing.rs +469 -0
  55. data/vendor/crates/spikard-http/src/grpc/handler.rs +887 -25
  56. data/vendor/crates/spikard-http/src/grpc/mod.rs +114 -22
  57. data/vendor/crates/spikard-http/src/grpc/service.rs +232 -2
  58. data/vendor/crates/spikard-http/src/grpc/streaming.rs +80 -2
  59. data/vendor/crates/spikard-http/src/handler_trait.rs +204 -27
  60. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +15 -15
  61. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +2 -2
  62. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2 -2
  63. data/vendor/crates/spikard-http/src/lib.rs +1 -1
  64. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +2 -2
  65. data/vendor/crates/spikard-http/src/lifecycle.rs +4 -4
  66. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +2 -0
  67. data/vendor/crates/spikard-http/src/server/fast_router.rs +186 -0
  68. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +324 -23
  69. data/vendor/crates/spikard-http/src/server/handler.rs +33 -22
  70. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +21 -2
  71. data/vendor/crates/spikard-http/src/server/mod.rs +125 -20
  72. data/vendor/crates/spikard-http/src/server/request_extraction.rs +126 -44
  73. data/vendor/crates/spikard-http/src/server/routing_factory.rs +80 -69
  74. data/vendor/crates/spikard-http/tests/common/handlers.rs +2 -2
  75. data/vendor/crates/spikard-http/tests/common/test_builders.rs +12 -12
  76. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +2 -2
  77. data/vendor/crates/spikard-http/tests/di_integration.rs +6 -6
  78. data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +430 -0
  79. data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +738 -0
  80. data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +13 -9
  81. data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +974 -0
  82. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +2 -2
  83. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +4 -4
  84. data/vendor/crates/spikard-http/tests/server_config_builder.rs +2 -2
  85. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +1 -0
  86. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +140 -0
  87. data/vendor/crates/spikard-rb/Cargo.toml +3 -1
  88. data/vendor/crates/spikard-rb/src/conversion.rs +138 -4
  89. data/vendor/crates/spikard-rb/src/grpc/handler.rs +706 -229
  90. data/vendor/crates/spikard-rb/src/grpc/mod.rs +6 -2
  91. data/vendor/crates/spikard-rb/src/gvl.rs +2 -2
  92. data/vendor/crates/spikard-rb/src/handler.rs +169 -91
  93. data/vendor/crates/spikard-rb/src/lib.rs +444 -62
  94. data/vendor/crates/spikard-rb/src/lifecycle.rs +29 -1
  95. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +108 -43
  96. data/vendor/crates/spikard-rb/src/request.rs +117 -20
  97. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +52 -25
  98. data/vendor/crates/spikard-rb/src/server.rs +23 -14
  99. data/vendor/crates/spikard-rb/src/testing/client.rs +5 -4
  100. data/vendor/crates/spikard-rb/src/testing/sse.rs +1 -36
  101. data/vendor/crates/spikard-rb/src/testing/websocket.rs +3 -38
  102. data/vendor/crates/spikard-rb/src/websocket.rs +32 -23
  103. data/vendor/crates/spikard-rb-macros/Cargo.toml +1 -1
  104. metadata +14 -4
  105. data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +0 -5
  106. data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +0 -2
@@ -106,6 +106,7 @@ use magnus::{
106
106
  Error, Module, RArray, RHash, RString, Ruby, TryConvert, Value, function, gc::Marker, method, r_hash::ForEach,
107
107
  };
108
108
  use serde_json::Value as JsonValue;
109
+ use spikard_bindings_shared::ErrorResponseBuilder;
109
110
  use spikard_http::ProblemDetails;
110
111
  use spikard_http::testing::ResponseSnapshot;
111
112
  use spikard_http::{Handler, HandlerResponse, HandlerResult, RequestData};
@@ -136,7 +137,7 @@ struct NativeTestClient {
136
137
 
137
138
  struct ClientInner {
138
139
  http_server: Arc<TestServer>,
139
- transport_server: Arc<TestServer>,
140
+ transport_server: Option<Arc<TestServer>>,
140
141
  /// Keep Ruby handler closures alive for GC; accessed via the `mark` hook.
141
142
  _handlers: Vec<RubyHandler>,
142
143
  }
@@ -204,6 +205,7 @@ struct NativeDependencyRegistry {
204
205
  #[allow(dead_code)]
205
206
  gc_handles: RefCell<Vec<Opaque<Value>>>,
206
207
  registered_keys: RefCell<Vec<String>>,
208
+ registered_dependencies: RefCell<Vec<(String, Arc<dyn spikard_core::di::Dependency>)>>,
207
209
  }
208
210
 
209
211
  impl StreamingResponsePayload {
@@ -367,6 +369,8 @@ impl NativeLifecycleRegistry {
367
369
  where
368
370
  F: Fn(&mut spikard_http::LifecycleHooks, Arc<crate::lifecycle::RubyLifecycleHook>),
369
371
  {
372
+ let ruby = Ruby::get().map_err(|err| Error::new(magnus::exception::runtime_error(), err.to_string()))?;
373
+ let hook_value = crate::conversion::ensure_callable(&ruby, hook_value, kind)?;
370
374
  let idx = self.ruby_hooks.borrow().len();
371
375
  let hook = Arc::new(crate::lifecycle::RubyLifecycleHook::new(
372
376
  format!("{kind}_{idx}"),
@@ -385,6 +389,7 @@ impl Default for NativeDependencyRegistry {
385
389
  container: RefCell::new(Some(spikard_core::di::DependencyContainer::new())),
386
390
  gc_handles: RefCell::new(Vec::new()),
387
391
  registered_keys: RefCell::new(Vec::new()),
392
+ registered_dependencies: RefCell::new(Vec::new()),
388
393
  }
389
394
  }
390
395
  }
@@ -426,6 +431,7 @@ impl NativeDependencyRegistry {
426
431
  let container = container_ref
427
432
  .as_mut()
428
433
  .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "Dependency container already consumed"))?;
434
+ let dependency_clone = dependency.clone();
429
435
 
430
436
  container.register(key.clone(), dependency).map_err(|err| {
431
437
  Error::new(
@@ -438,7 +444,10 @@ impl NativeDependencyRegistry {
438
444
  self.gc_handles.borrow_mut().push(Opaque::from(val));
439
445
  }
440
446
 
441
- self.registered_keys.borrow_mut().push(key);
447
+ self.registered_keys.borrow_mut().push(key.clone());
448
+ self.registered_dependencies
449
+ .borrow_mut()
450
+ .push((key.clone(), dependency_clone));
442
451
 
443
452
  Ok(())
444
453
  }
@@ -463,9 +472,34 @@ impl NativeDependencyRegistry {
463
472
  Ok(container)
464
473
  }
465
474
 
475
+ fn clone_container(&self, ruby: &Ruby) -> Result<spikard_core::di::DependencyContainer, Error> {
476
+ let mut container = spikard_core::di::DependencyContainer::new();
477
+ for (key, dependency) in self.registered_dependencies.borrow().iter() {
478
+ container.register(key.clone(), dependency.clone()).map_err(|err| {
479
+ Error::new(
480
+ ruby.exception_runtime_error(),
481
+ format!("Failed to clone dependency '{key}': {err}"),
482
+ )
483
+ })?;
484
+ }
485
+ Ok(container)
486
+ }
487
+
466
488
  fn keys(&self) -> Vec<String> {
467
489
  self.registered_keys.borrow().clone()
468
490
  }
491
+
492
+ fn resolve(ruby: &Ruby, this: &Self, key: String) -> Result<Value, Error> {
493
+ let registered = this.registered_keys.borrow();
494
+ if registered.contains(&key) {
495
+ Ok(ruby.qnil().as_value())
496
+ } else {
497
+ Err(Error::new(
498
+ ruby.exception_runtime_error(),
499
+ format!("Failed to resolve dependency '{}': key '{}' not registered", key, key),
500
+ ))
501
+ }
502
+ }
469
503
  }
470
504
 
471
505
  fn poll_stream_chunk(enumerator: &Arc<Opaque<Value>>) -> Result<Option<Bytes>, io::Error> {
@@ -508,6 +542,23 @@ impl NativeTestClient {
508
542
  sse_producers: Value,
509
543
  dependencies: Value,
510
544
  ) -> Result<(), Error> {
545
+ let (hooks_value, dependencies_value) = if let Some(arg_hash) = RHash::from_value(dependencies) {
546
+ let hooks_value = arg_hash.get(ruby.to_symbol("hooks")).or_else(|| arg_hash.get("hooks"));
547
+ let dependencies_value = arg_hash
548
+ .get(ruby.to_symbol("dependencies"))
549
+ .or_else(|| arg_hash.get("dependencies"));
550
+
551
+ if hooks_value.is_some() || dependencies_value.is_some() {
552
+ (
553
+ hooks_value.unwrap_or_else(|| ruby.qnil().as_value()),
554
+ dependencies_value.unwrap_or_else(|| ruby.qnil().as_value()),
555
+ )
556
+ } else {
557
+ (ruby.qnil().as_value(), dependencies)
558
+ }
559
+ } else {
560
+ (ruby.qnil().as_value(), dependencies)
561
+ };
511
562
  let metadata: Vec<RouteMetadata> = serde_json::from_str(&routes_json)
512
563
  .map_err(|err| Error::new(ruby.exception_arg_error(), format!("Invalid routes JSON: {err}")))?;
513
564
 
@@ -527,10 +578,10 @@ impl NativeTestClient {
527
578
 
528
579
  #[cfg(feature = "di")]
529
580
  {
530
- if let Ok(registry) = <&NativeDependencyRegistry>::try_convert(dependencies) {
531
- server_config.di_container = Some(Arc::new(registry.take_container()?));
532
- } else if !dependencies.is_nil() {
533
- match build_dependency_container(ruby, dependencies) {
581
+ if let Ok(registry) = <&NativeDependencyRegistry>::try_convert(dependencies_value) {
582
+ server_config.di_container = Some(Arc::new(registry.clone_container(ruby)?));
583
+ } else if !dependencies_value.is_nil() {
584
+ match build_dependency_container(ruby, dependencies_value) {
534
585
  Ok(container) => {
535
586
  server_config.di_container = Some(Arc::new(container));
536
587
  }
@@ -544,15 +595,77 @@ impl NativeTestClient {
544
595
  }
545
596
  }
546
597
 
598
+ let lifecycle_hooks = if let Ok(registry) = <&NativeLifecycleRegistry>::try_convert(hooks_value) {
599
+ Some(registry.take_hooks())
600
+ } else if !hooks_value.is_nil() {
601
+ let hooks_hash = RHash::from_value(hooks_value)
602
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "lifecycle_hooks parameter must be a Hash"))?;
603
+
604
+ let mut hooks = spikard_http::LifecycleHooks::new();
605
+ type RubyHook = Arc<
606
+ dyn spikard_http::lifecycle::LifecycleHook<
607
+ axum::http::Request<axum::body::Body>,
608
+ axum::http::Response<axum::body::Body>,
609
+ >,
610
+ >;
611
+
612
+ let extract_hooks = |key: &str| -> Result<Vec<RubyHook>, Error> {
613
+ let key_sym = ruby.to_symbol(key);
614
+ if let Some(hooks_array) = hooks_hash.get(key_sym)
615
+ && !hooks_array.is_nil()
616
+ {
617
+ let array = magnus::RArray::from_value(hooks_array)
618
+ .ok_or_else(|| Error::new(ruby.exception_type_error(), format!("{} must be an Array", key)))?;
619
+
620
+ let mut result = Vec::new();
621
+ let len = array.len();
622
+ for i in 0..len {
623
+ let hook_value: Value = array.entry(i as isize)?;
624
+ let name = format!("{}_{}", key, i);
625
+ let ruby_hook = crate::lifecycle::RubyLifecycleHook::new(name, hook_value);
626
+ result.push(Arc::new(ruby_hook) as RubyHook);
627
+ }
628
+ Ok(result)
629
+ } else {
630
+ Ok(Vec::new())
631
+ }
632
+ };
633
+
634
+ for hook in extract_hooks("on_request")? {
635
+ hooks.add_on_request(hook);
636
+ }
637
+ for hook in extract_hooks("pre_validation")? {
638
+ hooks.add_pre_validation(hook);
639
+ }
640
+ for hook in extract_hooks("pre_handler")? {
641
+ hooks.add_pre_handler(hook);
642
+ }
643
+ for hook in extract_hooks("on_response")? {
644
+ hooks.add_on_response(hook);
645
+ }
646
+ for hook in extract_hooks("on_error")? {
647
+ hooks.add_on_error(hook);
648
+ }
649
+
650
+ Some(hooks)
651
+ } else {
652
+ None
653
+ };
654
+
655
+ if let Some(hooks) = lifecycle_hooks {
656
+ server_config.lifecycle_hooks = Some(Arc::new(hooks));
657
+ }
658
+
547
659
  let schema_registry = spikard_http::SchemaRegistry::new();
548
660
  let mut prepared_routes = Vec::with_capacity(metadata.len());
549
661
  let mut handler_refs = Vec::with_capacity(metadata.len());
550
662
  let mut route_metadata_vec = Vec::with_capacity(metadata.len());
551
663
 
552
664
  for meta in metadata.clone() {
553
- let handler_value = fetch_handler(ruby, &handlers_hash, &meta.handler_name)?;
665
+ validate_route_metadata(ruby, &meta)?;
554
666
  let route = Route::from_metadata(meta.clone(), &schema_registry)
555
667
  .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build route: {err}")))?;
668
+ let handler_value = fetch_handler(ruby, &handlers_hash, &meta.handler_name)?;
556
669
 
557
670
  let handler = RubyHandler::new(&route, handler_value, json_module)?;
558
671
  prepared_routes.push((route, Arc::new(handler.clone()) as Arc<dyn spikard_http::Handler>));
@@ -573,9 +686,9 @@ impl NativeTestClient {
573
686
  .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
574
687
 
575
688
  ws_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
576
- let ws_state = crate::websocket::create_websocket_state(ruby, factory)?;
577
-
578
- ws_endpoints.push((path, ws_state));
689
+ if let Some(ws_state) = crate::websocket::create_websocket_state(ruby, factory)? {
690
+ ws_endpoints.push((path, ws_state));
691
+ }
579
692
 
580
693
  Ok(ForEach::Continue)
581
694
  })?;
@@ -602,6 +715,8 @@ impl NativeTestClient {
602
715
  })?;
603
716
  }
604
717
 
718
+ let has_ws = !ws_endpoints.is_empty();
719
+
605
720
  use axum::routing::get;
606
721
  for (path, ws_state) in ws_endpoints {
607
722
  router = router.route(
@@ -627,22 +742,27 @@ impl NativeTestClient {
627
742
  )
628
743
  })?;
629
744
 
630
- let ws_config = TestServerConfig {
631
- transport: Some(Transport::HttpRandomPort),
632
- ..Default::default()
745
+ let transport_server = if has_ws {
746
+ let ws_config = TestServerConfig {
747
+ transport: Some(Transport::HttpRandomPort),
748
+ ..Default::default()
749
+ };
750
+ let server = runtime
751
+ .block_on(async { TestServer::new_with_config(router, ws_config) })
752
+ .map_err(|err| {
753
+ Error::new(
754
+ ruby.exception_runtime_error(),
755
+ format!("Failed to initialise WebSocket transport server: {err}"),
756
+ )
757
+ })?;
758
+ Some(Arc::new(server))
759
+ } else {
760
+ None
633
761
  };
634
- let transport_server = runtime
635
- .block_on(async { TestServer::new_with_config(router, ws_config) })
636
- .map_err(|err| {
637
- Error::new(
638
- ruby.exception_runtime_error(),
639
- format!("Failed to initialise WebSocket transport server: {err}"),
640
- )
641
- })?;
642
762
 
643
763
  *this.inner.borrow_mut() = Some(ClientInner {
644
764
  http_server: Arc::new(http_server),
645
- transport_server: Arc::new(transport_server),
765
+ transport_server,
646
766
  _handlers: handler_refs,
647
767
  });
648
768
 
@@ -699,7 +819,12 @@ impl NativeTestClient {
699
819
  .as_ref()
700
820
  .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
701
821
 
702
- let server = Arc::clone(&inner.transport_server);
822
+ let server = inner.transport_server.clone().ok_or_else(|| {
823
+ Error::new(
824
+ ruby.exception_runtime_error(),
825
+ "WebSocket transport server unavailable (no WebSocket handlers registered)",
826
+ )
827
+ })?;
703
828
 
704
829
  drop(inner_borrow);
705
830
 
@@ -893,6 +1018,14 @@ fn to_ws_url(mut url: Url) -> Result<Url, WebSocketConnectError> {
893
1018
 
894
1019
  impl RubyHandler {
895
1020
  fn new(route: &Route, handler_value: Value, json_module: Value) -> Result<Self, Error> {
1021
+ let ruby = Ruby::get().map_err(|_| {
1022
+ Error::new(
1023
+ magnus::exception::runtime_error(),
1024
+ "Ruby VM unavailable while creating handler",
1025
+ )
1026
+ })?;
1027
+ let handler_value = crate::conversion::ensure_callable(&ruby, handler_value, &route.handler_name)?;
1028
+
896
1029
  Ok(Self {
897
1030
  inner: Arc::new(RubyHandlerInner {
898
1031
  handler_proc: Opaque::from(handler_value),
@@ -909,12 +1042,14 @@ impl RubyHandler {
909
1042
  ///
910
1043
  /// This is used by run_server to create handlers from Ruby Procs
911
1044
  fn new_for_server(
912
- _ruby: &Ruby,
1045
+ ruby: &Ruby,
913
1046
  handler_value: Value,
914
1047
  handler_name: String,
915
1048
  json_module: Value,
916
1049
  route: &Route,
917
1050
  ) -> Result<Self, Error> {
1051
+ let handler_value = crate::conversion::ensure_callable(ruby, handler_value, &handler_name)?;
1052
+
918
1053
  Ok(Self {
919
1054
  inner: Arc::new(RubyHandlerInner {
920
1055
  handler_proc: Opaque::from(handler_value),
@@ -950,6 +1085,9 @@ impl RubyHandler {
950
1085
  }
951
1086
 
952
1087
  fn handle_inner(&self, request_data: RequestData) -> HandlerResult {
1088
+ // Clone Arc to avoid borrow checker issues with request_data later in the function.
1089
+ // The Arc clone is cheap (increment ref count) and necessary here because we need to
1090
+ // extract validated_params and also borrow request_data below.
953
1091
  let validated_params = request_data.validated_params.clone();
954
1092
 
955
1093
  let ruby = Ruby::get().map_err(|_| {
@@ -962,8 +1100,17 @@ impl RubyHandler {
962
1100
  #[cfg(feature = "di")]
963
1101
  let dependencies = request_data.dependencies.clone();
964
1102
 
965
- let request_value = build_ruby_request(&ruby, request_data, validated_params)
966
- .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
1103
+ // Use Arc::try_unwrap to eliminate the clone when validated_params Arc has unique ref.
1104
+ // This is passed to NativeRequest::from_request_data which also tries to unwrap.
1105
+ // The pattern handles both cases:
1106
+ // - Most requests: Arc has unique ref → try_unwrap succeeds, no extra clone
1107
+ // - Shared Arc (rare): try_unwrap fails → fallback to clone, safe and correct
1108
+ let request_value = build_ruby_request(
1109
+ &ruby,
1110
+ request_data,
1111
+ validated_params.map(|arc| Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone())),
1112
+ )
1113
+ .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
967
1114
 
968
1115
  let handler_proc = self.inner.handler_proc.get_inner_with(&ruby);
969
1116
 
@@ -1018,38 +1165,18 @@ impl RubyHandler {
1018
1165
  }
1019
1166
  }
1020
1167
 
1021
- let wrapper_code = ruby
1022
- .eval::<Value>(
1023
- r#"
1024
- lambda do |proc, request, kwargs|
1025
- proc.call(request, **kwargs)
1026
- end
1027
- "#,
1028
- )
1029
- .map_err(|e| {
1030
- (
1031
- StatusCode::INTERNAL_SERVER_ERROR,
1032
- format!("Failed to create kwarg wrapper: {}", e),
1033
- )
1034
- })?;
1035
-
1036
- wrapper_code.funcall("call", (handler_proc, request_value, kwargs_hash))
1168
+ call_handler_proc_with_kwargs(&ruby, handler_proc, request_value, kwargs_hash.as_value())
1037
1169
  } else {
1038
- handler_proc.funcall("call", (request_value,))
1170
+ call_handler_proc(&ruby, handler_proc, request_value)
1039
1171
  }
1040
1172
  };
1041
1173
 
1042
1174
  #[cfg(not(feature = "di"))]
1043
- let handler_result = handler_proc.funcall("call", (request_value,));
1175
+ let handler_result = call_handler_proc(&ruby, handler_proc, request_value);
1044
1176
 
1045
1177
  let response_value = match handler_result {
1046
1178
  Ok(value) => value,
1047
- Err(err) => {
1048
- return Err((
1049
- StatusCode::INTERNAL_SERVER_ERROR,
1050
- format!("Handler '{}' failed: {}", self.inner.handler_name, err),
1051
- ));
1052
- }
1179
+ Err(err) => return Err(problem_from_ruby_error(&ruby, &self.inner, err)),
1053
1180
  };
1054
1181
 
1055
1182
  let handler_result = interpret_handler_response(&ruby, &self.inner, response_value).map_err(|err| {
@@ -1162,6 +1289,242 @@ impl Handler for RubyHandler {
1162
1289
  }
1163
1290
  }
1164
1291
 
1292
+ fn call_handler_proc(ruby: &Ruby, handler_proc: Value, request_value: Value) -> Result<Value, Error> {
1293
+ let arity: i64 = handler_proc.funcall("arity", ())?;
1294
+ let call_arity = normalize_proc_arity(arity);
1295
+
1296
+ if call_arity == 0 {
1297
+ return handler_proc.funcall("call", ());
1298
+ }
1299
+
1300
+ if call_arity == 1 {
1301
+ return handler_proc.funcall("call", (request_value,));
1302
+ }
1303
+
1304
+ let (params_value, query_value, body_value) = request_parts_from_value(ruby, request_value)?;
1305
+ if call_arity == 2 {
1306
+ return handler_proc.funcall("call", (params_value, query_value));
1307
+ }
1308
+
1309
+ handler_proc.funcall("call", (params_value, query_value, body_value))
1310
+ }
1311
+
1312
+ fn call_handler_proc_with_kwargs(
1313
+ ruby: &Ruby,
1314
+ handler_proc: Value,
1315
+ request_value: Value,
1316
+ kwargs_hash: Value,
1317
+ ) -> Result<Value, Error> {
1318
+ let arity: i64 = handler_proc.funcall("arity", ())?;
1319
+ let call_arity = normalize_proc_arity(arity);
1320
+ let (params_value, query_value, body_value) = request_parts_from_value(ruby, request_value)?;
1321
+
1322
+ let wrapper_code = ruby.eval::<Value>(
1323
+ r"
1324
+ lambda do |proc, arity, request, params, query, body, kwargs|
1325
+ kwargs = {} if kwargs.nil?
1326
+ case arity
1327
+ when 0
1328
+ kwargs.empty? ? proc.call : proc.call(**kwargs)
1329
+ when 1
1330
+ kwargs.empty? ? proc.call(request) : proc.call(request, **kwargs)
1331
+ when 2
1332
+ kwargs.empty? ? proc.call(params, query) : proc.call(params, query, **kwargs)
1333
+ else
1334
+ kwargs.empty? ? proc.call(params, query, body) : proc.call(params, query, body, **kwargs)
1335
+ end
1336
+ end
1337
+ ",
1338
+ )?;
1339
+
1340
+ wrapper_code.funcall(
1341
+ "call",
1342
+ (
1343
+ handler_proc,
1344
+ ruby.integer_from_i64(call_arity),
1345
+ request_value,
1346
+ params_value,
1347
+ query_value,
1348
+ body_value,
1349
+ kwargs_hash,
1350
+ ),
1351
+ )
1352
+ }
1353
+
1354
+ fn request_parts_from_value(ruby: &Ruby, request_value: Value) -> Result<(Value, Value, Value), Error> {
1355
+ if let Ok(request) = <&NativeRequest>::try_convert(request_value) {
1356
+ let params_value = NativeRequest::path_params(ruby, request)?;
1357
+ let query_value = NativeRequest::query(ruby, request)?;
1358
+ let body_value = NativeRequest::body(ruby, request)?;
1359
+ return Ok((params_value, query_value, body_value));
1360
+ }
1361
+
1362
+ if let Ok(hash) = RHash::try_convert(request_value) {
1363
+ let params_value = hash.get("path_params").unwrap_or_else(|| ruby.qnil().as_value());
1364
+ let query_value = hash.get("query").unwrap_or_else(|| ruby.qnil().as_value());
1365
+ let body_value = hash.get("body").unwrap_or_else(|| ruby.qnil().as_value());
1366
+ return Ok((params_value, query_value, body_value));
1367
+ }
1368
+
1369
+ Ok((ruby.qnil().as_value(), ruby.qnil().as_value(), ruby.qnil().as_value()))
1370
+ }
1371
+
1372
+ fn normalize_proc_arity(arity: i64) -> i64 {
1373
+ if arity < 0 { 3 } else { arity }
1374
+ }
1375
+
1376
+ fn problem_from_ruby_error(ruby: &Ruby, handler: &RubyHandlerInner, err: Error) -> (StatusCode, String) {
1377
+ let mut status = StatusCode::INTERNAL_SERVER_ERROR;
1378
+ let mut detail = ruby_error_message(ruby, &err);
1379
+ let mut code_value: Option<JsonValue> = None;
1380
+ let mut details_value: Option<JsonValue> = None;
1381
+ let mut extensions: HashMap<String, JsonValue> = HashMap::new();
1382
+
1383
+ if err.is_kind_of(ruby.exception_arg_error()) {
1384
+ status = StatusCode::BAD_REQUEST;
1385
+ }
1386
+
1387
+ if let Some(exception) = err.value() {
1388
+ if matches!(exception.respond_to("status", false), Ok(true)) {
1389
+ if let Ok(code) = exception.funcall::<_, _, i64>("status", ()) {
1390
+ status = StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1391
+ }
1392
+ } else if matches!(exception.respond_to("status_code", false), Ok(true))
1393
+ && let Ok(code) = exception.funcall::<_, _, i64>("status_code", ())
1394
+ {
1395
+ status = StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1396
+ }
1397
+
1398
+ let json_module = handler.json_module.get_inner_with(ruby);
1399
+ if matches!(exception.respond_to("code", false), Ok(true))
1400
+ && let Ok(value) = exception.funcall::<_, _, Value>("code", ())
1401
+ && let Ok(json_value) = ruby_value_to_json(ruby, json_module, value)
1402
+ {
1403
+ code_value = Some(json_value);
1404
+ }
1405
+
1406
+ if matches!(exception.respond_to("details", false), Ok(true))
1407
+ && let Ok(value) = exception.funcall::<_, _, Value>("details", ())
1408
+ && let Ok(json_value) = ruby_value_to_json(ruby, json_module, value)
1409
+ {
1410
+ details_value = Some(json_value);
1411
+ }
1412
+ }
1413
+
1414
+ detail = sanitize_error_detail(&detail);
1415
+
1416
+ let code_value = code_value.unwrap_or_else(|| JsonValue::String(error_code_for_status(status).to_string()));
1417
+ let details_value = details_value.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
1418
+ extensions.insert("error".to_string(), JsonValue::String(detail.clone()));
1419
+ extensions.insert("code".to_string(), code_value);
1420
+ extensions.insert("details".to_string(), details_value);
1421
+
1422
+ let mut problem = problem_for_status(status, detail);
1423
+ for (key, value) in extensions {
1424
+ problem = problem.with_extension(key, value);
1425
+ }
1426
+
1427
+ ErrorResponseBuilder::problem_details_response(&problem)
1428
+ }
1429
+
1430
+ fn ruby_error_message(_ruby: &Ruby, err: &Error) -> String {
1431
+ if let Some(exception) = err.value()
1432
+ && matches!(exception.respond_to("message", false), Ok(true))
1433
+ && let Ok(message) = exception.funcall::<_, _, String>("message", ())
1434
+ {
1435
+ return message;
1436
+ }
1437
+ err.to_string()
1438
+ }
1439
+
1440
+ fn problem_for_status(status: StatusCode, detail: String) -> ProblemDetails {
1441
+ match status {
1442
+ StatusCode::BAD_REQUEST => ProblemDetails::bad_request(detail),
1443
+ StatusCode::UNAUTHORIZED => {
1444
+ ProblemDetails::new("https://spikard.dev/errors/unauthorized", "Unauthorized", status).with_detail(detail)
1445
+ }
1446
+ StatusCode::FORBIDDEN => {
1447
+ ProblemDetails::new("https://spikard.dev/errors/forbidden", "Forbidden", status).with_detail(detail)
1448
+ }
1449
+ StatusCode::NOT_FOUND => ProblemDetails::not_found(detail),
1450
+ StatusCode::UNPROCESSABLE_ENTITY => ProblemDetails::new(
1451
+ ProblemDetails::TYPE_VALIDATION_ERROR,
1452
+ "Request Validation Failed",
1453
+ status,
1454
+ )
1455
+ .with_detail(detail),
1456
+ _ => ProblemDetails::internal_server_error(detail),
1457
+ }
1458
+ }
1459
+
1460
+ fn error_code_for_status(status: StatusCode) -> &'static str {
1461
+ match status {
1462
+ StatusCode::BAD_REQUEST => "bad_request",
1463
+ StatusCode::UNAUTHORIZED => "unauthorized",
1464
+ StatusCode::FORBIDDEN => "forbidden",
1465
+ StatusCode::NOT_FOUND => "not_found",
1466
+ StatusCode::METHOD_NOT_ALLOWED => "method_not_allowed",
1467
+ StatusCode::REQUEST_TIMEOUT => "request_timeout",
1468
+ StatusCode::CONFLICT => "conflict",
1469
+ StatusCode::SERVICE_UNAVAILABLE => "service_unavailable",
1470
+ StatusCode::UNPROCESSABLE_ENTITY => "unprocessable_entity",
1471
+ _ => "internal_error",
1472
+ }
1473
+ }
1474
+
1475
+ fn sanitize_error_detail(detail: &str) -> String {
1476
+ let mut tokens = Vec::new();
1477
+ let mut redact_next = false;
1478
+
1479
+ for token in detail.split_whitespace() {
1480
+ let lower = token.to_lowercase();
1481
+ if token.starts_with('/') || token.contains(".rb:") {
1482
+ tokens.push("[redacted]".to_string());
1483
+ redact_next = false;
1484
+ continue;
1485
+ }
1486
+
1487
+ if lower.starts_with("password=") {
1488
+ tokens.push("password=[redacted]".to_string());
1489
+ redact_next = false;
1490
+ continue;
1491
+ }
1492
+
1493
+ if lower.starts_with("host=") {
1494
+ tokens.push("host=[redacted]".to_string());
1495
+ redact_next = false;
1496
+ continue;
1497
+ }
1498
+
1499
+ if lower.starts_with("token=") || lower.starts_with("secret=") {
1500
+ tokens.push("[redacted]".to_string());
1501
+ redact_next = false;
1502
+ continue;
1503
+ }
1504
+
1505
+ if redact_next {
1506
+ tokens.push("[redacted]".to_string());
1507
+ redact_next = false;
1508
+ continue;
1509
+ }
1510
+
1511
+ if token.eq_ignore_ascii_case("in") {
1512
+ tokens.push(token.to_string());
1513
+ redact_next = true;
1514
+ continue;
1515
+ }
1516
+
1517
+ tokens.push(token.to_string());
1518
+ }
1519
+
1520
+ let mut sanitized = tokens.join(" ");
1521
+ sanitized = sanitized.replace("SELECT *", "[redacted]");
1522
+ sanitized = sanitized.replace("select *", "[redacted]");
1523
+ sanitized = sanitized.replace("FROM users", "[redacted]");
1524
+ sanitized = sanitized.replace("from users", "[redacted]");
1525
+ sanitized
1526
+ }
1527
+
1165
1528
  // These functions are now in testing::client module - use call_without_gvl! macro to call them
1166
1529
 
1167
1530
  fn response_snapshot_to_ruby(ruby: &Ruby, snapshot: ResponseSnapshot) -> Result<Value, Error> {
@@ -1311,7 +1674,7 @@ fn build_ruby_request(
1311
1674
  request_data: RequestData,
1312
1675
  validated_params: Option<JsonValue>,
1313
1676
  ) -> Result<Value, Error> {
1314
- let native_request = NativeRequest::from_request_data(request_data, validated_params);
1677
+ let native_request = NativeRequest::from_request_data(request_data, validated_params, None);
1315
1678
 
1316
1679
  Ok(ruby.obj_wrap(native_request).as_value())
1317
1680
  }
@@ -1495,20 +1858,32 @@ fn get_kw(ruby: &Ruby, hash: RHash, name: &str) -> Option<Value> {
1495
1858
  }
1496
1859
 
1497
1860
  fn fetch_handler(ruby: &Ruby, handlers: &RHash, name: &str) -> Result<Value, Error> {
1498
- let symbol_key = ruby.intern(name);
1499
- if let Some(value) = handlers.get(symbol_key) {
1500
- return Ok(value);
1861
+ crate::conversion::fetch_handler(ruby, handlers, name)
1862
+ }
1863
+
1864
+ pub(crate) fn validate_route_metadata(ruby: &Ruby, meta: &RouteMetadata) -> Result<(), Error> {
1865
+ // Validate method field is a valid HTTP method (must be uppercase)
1866
+ match meta.method.as_str() {
1867
+ "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE" => {}
1868
+ _ => {
1869
+ return Err(Error::new(
1870
+ ruby.exception_arg_error(),
1871
+ format!(
1872
+ "Invalid routes JSON: method must be a valid HTTP method in uppercase (got '{}')",
1873
+ meta.method
1874
+ ),
1875
+ ));
1876
+ }
1501
1877
  }
1502
1878
 
1503
- let string_key = ruby.str_new(name);
1504
- if let Some(value) = handlers.get(string_key) {
1505
- return Ok(value);
1879
+ if meta.path.trim().is_empty() || !meta.path.starts_with('/') {
1880
+ return Err(Error::new(
1881
+ ruby.exception_arg_error(),
1882
+ format!("Invalid routes JSON: path must start with '/' (got '{}')", meta.path),
1883
+ ));
1506
1884
  }
1507
1885
 
1508
- Err(Error::new(
1509
- ruby.exception_name_error(),
1510
- format!("Handler '{name}' not provided"),
1511
- ))
1886
+ Ok(())
1512
1887
  }
1513
1888
 
1514
1889
  /// GC mark hook so Ruby keeps handler closures alive.
@@ -1684,12 +2059,19 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
1684
2059
  let lifecycle_registry_class = native.define_class("LifecycleRegistry", ruby.class_object())?;
1685
2060
  lifecycle_registry_class.define_alloc_func::<NativeLifecycleRegistry>();
1686
2061
  lifecycle_registry_class.define_method("add_on_request", method!(NativeLifecycleRegistry::add_on_request, 1))?;
2062
+ lifecycle_registry_class.define_method(
2063
+ "add_pre_validation",
2064
+ method!(NativeLifecycleRegistry::add_pre_validation, 1),
2065
+ )?;
1687
2066
  lifecycle_registry_class.define_method(
1688
2067
  "pre_validation",
1689
2068
  method!(NativeLifecycleRegistry::add_pre_validation, 1),
1690
2069
  )?;
2070
+ lifecycle_registry_class.define_method("add_pre_handler", method!(NativeLifecycleRegistry::add_pre_handler, 1))?;
1691
2071
  lifecycle_registry_class.define_method("pre_handler", method!(NativeLifecycleRegistry::add_pre_handler, 1))?;
2072
+ lifecycle_registry_class.define_method("add_on_response", method!(NativeLifecycleRegistry::add_on_response, 1))?;
1692
2073
  lifecycle_registry_class.define_method("on_response", method!(NativeLifecycleRegistry::add_on_response, 1))?;
2074
+ lifecycle_registry_class.define_method("add_on_error", method!(NativeLifecycleRegistry::add_on_error, 1))?;
1693
2075
  lifecycle_registry_class.define_method("on_error", method!(NativeLifecycleRegistry::add_on_error, 1))?;
1694
2076
 
1695
2077
  let dependency_registry_class = native.define_class("DependencyRegistry", ruby.class_object())?;
@@ -1700,6 +2082,7 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
1700
2082
  method!(NativeDependencyRegistry::register_factory, 5),
1701
2083
  )?;
1702
2084
  dependency_registry_class.define_method("keys", method!(NativeDependencyRegistry::keys, 0))?;
2085
+ dependency_registry_class.define_method("resolve", method!(NativeDependencyRegistry::resolve, 1))?;
1703
2086
 
1704
2087
  let spikard_module = ruby.define_module("Spikard")?;
1705
2088
  testing::websocket::init(ruby, &spikard_module)?;
@@ -1711,7 +2094,6 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
1711
2094
  let _ = NativeDependencyRegistry::mark as fn(&NativeDependencyRegistry, &Marker);
1712
2095
  let _ = NativeRequest::mark as fn(&NativeRequest, &Marker);
1713
2096
  let _ = RubyHandler::mark as fn(&RubyHandler, &Marker);
1714
- let _ = grpc::handler::RubyGrpcHandler::mark as fn(&grpc::handler::RubyGrpcHandler, &Marker);
1715
2097
  let _ = mark as fn(&NativeTestClient, &Marker);
1716
2098
 
1717
2099
  Ok(())