spikard 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -508
  3. data/ext/spikard_rb/Cargo.lock +3287 -0
  4. data/ext/spikard_rb/Cargo.toml +1 -1
  5. data/ext/spikard_rb/extconf.rb +3 -3
  6. data/lib/spikard/app.rb +72 -49
  7. data/lib/spikard/background.rb +38 -7
  8. data/lib/spikard/testing.rb +42 -4
  9. data/lib/spikard/version.rb +1 -1
  10. data/sig/spikard.rbs +4 -0
  11. data/vendor/crates/spikard-bindings-shared/Cargo.toml +1 -1
  12. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
  13. data/vendor/crates/spikard-core/Cargo.toml +1 -1
  14. data/vendor/crates/spikard-core/src/http.rs +1 -0
  15. data/vendor/crates/spikard-core/src/lifecycle.rs +63 -0
  16. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +136 -0
  17. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +37 -0
  18. data/vendor/crates/spikard-core/tests/error_mapper.rs +761 -0
  19. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +106 -0
  20. data/vendor/crates/spikard-core/tests/parameters_full.rs +701 -0
  21. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +301 -0
  22. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +67 -0
  23. data/vendor/crates/spikard-core/tests/validation_coverage.rs +250 -0
  24. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +45 -0
  25. data/vendor/crates/spikard-http/Cargo.toml +1 -1
  26. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +502 -0
  27. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +648 -0
  28. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +58 -0
  29. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +1207 -0
  30. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2262 -0
  31. data/vendor/crates/spikard-http/src/testing/test_client.rs +155 -2
  32. data/vendor/crates/spikard-http/src/testing.rs +171 -0
  33. data/vendor/crates/spikard-http/src/websocket.rs +79 -6
  34. data/vendor/crates/spikard-http/tests/auth_integration.rs +647 -0
  35. data/vendor/crates/spikard-http/tests/common/test_builders.rs +633 -0
  36. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +162 -0
  37. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +389 -0
  38. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +513 -0
  39. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +244 -0
  40. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +200 -0
  41. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +82 -0
  42. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +464 -0
  43. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +286 -0
  44. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +118 -0
  45. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +99 -0
  46. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +206 -0
  47. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +281 -0
  48. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +121 -0
  49. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +584 -0
  50. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +130 -0
  51. data/vendor/crates/spikard-http/tests/test_client_requests.rs +167 -0
  52. data/vendor/crates/spikard-http/tests/testing_helpers.rs +87 -0
  53. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +156 -0
  54. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +82 -0
  55. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +440 -0
  56. data/vendor/crates/spikard-http/tests/websocket_integration.rs +152 -0
  57. data/vendor/crates/spikard-rb/Cargo.toml +1 -1
  58. data/vendor/crates/spikard-rb/src/gvl.rs +80 -0
  59. data/vendor/crates/spikard-rb/src/handler.rs +12 -9
  60. data/vendor/crates/spikard-rb/src/lib.rs +137 -124
  61. data/vendor/crates/spikard-rb/src/request.rs +342 -0
  62. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +1 -8
  63. data/vendor/crates/spikard-rb/src/server.rs +1 -8
  64. data/vendor/crates/spikard-rb/src/testing/client.rs +168 -9
  65. data/vendor/crates/spikard-rb/src/websocket.rs +119 -30
  66. data/vendor/crates/spikard-rb-macros/Cargo.toml +14 -0
  67. data/vendor/crates/spikard-rb-macros/src/lib.rs +52 -0
  68. metadata +44 -1
@@ -25,6 +25,7 @@ use std::sync::Arc;
25
25
  use crate::conversion::{
26
26
  json_to_ruby, json_to_ruby_with_uploads, map_to_ruby_hash, multimap_to_ruby_hash, ruby_value_to_json,
27
27
  };
28
+ use crate::gvl::with_gvl;
28
29
 
29
30
  static KEY_METHOD: LazyId = LazyId::new("method");
30
31
  static KEY_PATH: LazyId = LazyId::new("path");
@@ -241,15 +242,17 @@ impl RubyHandler {
241
242
 
242
243
  /// Handle a request synchronously.
243
244
  pub fn handle(&self, request_data: RequestData) -> HandlerResult {
244
- let result = std::panic::catch_unwind(AssertUnwindSafe(|| self.handle_inner(request_data)));
245
- match result {
246
- Ok(res) => res,
247
- Err(_) => Err(ErrorResponseBuilder::structured_error(
248
- StatusCode::INTERNAL_SERVER_ERROR,
249
- "panic",
250
- "Unexpected panic while executing Ruby handler",
251
- )),
252
- }
245
+ with_gvl(|| {
246
+ let result = std::panic::catch_unwind(AssertUnwindSafe(|| self.handle_inner(request_data)));
247
+ match result {
248
+ Ok(res) => res,
249
+ Err(_) => Err(ErrorResponseBuilder::structured_error(
250
+ StatusCode::INTERNAL_SERVER_ERROR,
251
+ "panic",
252
+ "Unexpected panic while executing Ruby handler",
253
+ )),
254
+ }
255
+ })
253
256
  }
254
257
 
255
258
  fn handle_inner(&self, request_data: RequestData) -> HandlerResult {
@@ -40,7 +40,6 @@ use axum::body::Body;
40
40
  use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
41
41
  use axum_test::{TestServer, TestServerConfig, Transport};
42
42
  use bytes::Bytes;
43
- use cookie::Cookie;
44
43
  use magnus::prelude::*;
45
44
  use magnus::value::{InnerValue, Opaque};
46
45
  use magnus::{
@@ -48,9 +47,7 @@ use magnus::{
48
47
  };
49
48
  use serde_json::Value as JsonValue;
50
49
  use spikard_http::ProblemDetails;
51
- use spikard_http::testing::{
52
- MultipartFilePart, ResponseSnapshot, SnapshotError, build_multipart_body, encode_urlencoded_body, snapshot_response,
53
- };
50
+ use spikard_http::testing::ResponseSnapshot;
54
51
  use spikard_http::{Handler, HandlerResponse, HandlerResult, RequestData};
55
52
  use spikard_http::{Route, RouteMetadata, SchemaValidator};
56
53
  use std::cell::RefCell;
@@ -82,22 +79,8 @@ struct ClientInner {
82
79
  _handlers: Vec<RubyHandler>,
83
80
  }
84
81
 
85
- struct RequestConfig {
86
- query: Option<JsonValue>,
87
- headers: HashMap<String, String>,
88
- cookies: HashMap<String, String>,
89
- body: Option<RequestBody>,
90
- }
91
-
92
- enum RequestBody {
93
- Json(JsonValue),
94
- Form(JsonValue),
95
- Raw(String),
96
- Multipart {
97
- form_data: Vec<(String, String)>,
98
- files: Vec<MultipartFilePart>,
99
- },
100
- }
82
+ // Re-export from testing::client to avoid duplication
83
+ use testing::client::{RequestBody, RequestConfig};
101
84
 
102
85
  #[derive(Clone)]
103
86
  struct RubyHandler {
@@ -451,15 +434,6 @@ fn ruby_value_to_bytes(value: Value) -> Result<Bytes, io::Error> {
451
434
  Err(io::Error::other("Streaming chunks must be Strings or Arrays of bytes"))
452
435
  }
453
436
 
454
- struct TestResponseData {
455
- status: u16,
456
- headers: HashMap<String, String>,
457
- body_text: Option<String>,
458
- }
459
-
460
- #[derive(Debug)]
461
- struct NativeRequestError(String);
462
-
463
437
  impl NativeTestClient {
464
438
  #[allow(clippy::too_many_arguments)]
465
439
  fn initialize(
@@ -537,14 +511,7 @@ impl NativeTestClient {
537
511
  .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
538
512
 
539
513
  ws_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
540
- let handler_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
541
- Error::new(
542
- ruby.exception_runtime_error(),
543
- format!("Failed to create WebSocket handler: {}", e),
544
- )
545
- })?;
546
-
547
- let ws_state = crate::websocket::create_websocket_state(ruby, handler_instance)?;
514
+ let ws_state = crate::websocket::create_websocket_state(ruby, factory)?;
548
515
 
549
516
  ws_endpoints.push((path, ws_state));
550
517
 
@@ -636,19 +603,25 @@ impl NativeTestClient {
636
603
  let request_config = parse_request_config(ruby, options)?;
637
604
 
638
605
  let runtime = crate::server::global_runtime(ruby)?;
639
- let response = runtime
640
- .block_on(execute_request(
641
- inner.http_server.clone(),
642
- http_method,
643
- path.clone(),
644
- request_config,
645
- ))
646
- .map_err(|err| {
647
- Error::new(
648
- ruby.exception_runtime_error(),
649
- format!("Request failed for {method_upper} {path}: {}", err.0),
650
- )
651
- })?;
606
+ let server = inner.http_server.clone();
607
+ let path_value = path.clone();
608
+ let response = crate::call_without_gvl!(
609
+ testing::client::block_on_request,
610
+ args: (
611
+ runtime, &tokio::runtime::Runtime,
612
+ server, Arc<TestServer>,
613
+ http_method, Method,
614
+ path_value, String,
615
+ request_config, testing::client::RequestConfig
616
+ ),
617
+ return_type: Result<testing::client::TestResponseData, testing::client::NativeRequestError>
618
+ )
619
+ .map_err(|err| {
620
+ Error::new(
621
+ ruby.exception_runtime_error(),
622
+ format!("Request failed for {method_upper} {path}: {}", err.0),
623
+ )
624
+ })?;
652
625
 
653
626
  response_to_ruby(ruby, response)
654
627
  }
@@ -700,20 +673,26 @@ impl NativeTestClient {
700
673
  .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
701
674
 
702
675
  let runtime = crate::server::global_runtime(ruby)?;
676
+ let server = inner.http_server.clone();
677
+ let http_method = Method::GET;
703
678
  let request_config = RequestConfig {
704
679
  query: None,
705
680
  headers: HashMap::new(),
706
681
  cookies: HashMap::new(),
707
682
  body: None,
708
683
  };
709
- let response = runtime
710
- .block_on(execute_request(
711
- inner.http_server.clone(),
712
- Method::GET,
713
- path.clone(),
714
- request_config,
715
- ))
716
- .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("SSE request failed: {}", err.0)))?;
684
+ let response = crate::call_without_gvl!(
685
+ testing::client::block_on_request,
686
+ args: (
687
+ runtime, &tokio::runtime::Runtime,
688
+ server, Arc<TestServer>,
689
+ http_method, Method,
690
+ path, String,
691
+ request_config, RequestConfig
692
+ ),
693
+ return_type: Result<testing::client::TestResponseData, testing::client::NativeRequestError>
694
+ )
695
+ .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("SSE request failed: {}", err.0)))?;
717
696
 
718
697
  let body = response.body_text.unwrap_or_default().into_bytes();
719
698
  let snapshot = ResponseSnapshot {
@@ -724,6 +703,88 @@ impl NativeTestClient {
724
703
 
725
704
  testing::sse::sse_stream_from_response(ruby, &snapshot)
726
705
  }
706
+
707
+ fn graphql(
708
+ ruby: &Ruby,
709
+ this: &Self,
710
+ query: String,
711
+ variables: Value,
712
+ operation_name: Value,
713
+ ) -> Result<Value, Error> {
714
+ let (_status, response) = Self::execute_graphql_impl(ruby, this, query, variables, operation_name)?;
715
+ Ok(response)
716
+ }
717
+
718
+ fn graphql_with_status(
719
+ ruby: &Ruby,
720
+ this: &Self,
721
+ query: String,
722
+ variables: Value,
723
+ operation_name: Value,
724
+ ) -> Result<Value, Error> {
725
+ let (status, response) = Self::execute_graphql_impl(ruby, this, query, variables, operation_name)?;
726
+
727
+ let array = ruby.ary_new_capa(2);
728
+ array.push(ruby.integer_from_i64(status as i64))?;
729
+ array.push(response)?;
730
+ Ok(array.as_value())
731
+ }
732
+
733
+ fn execute_graphql_impl(
734
+ ruby: &Ruby,
735
+ this: &Self,
736
+ query: String,
737
+ variables: Value,
738
+ operation_name: Value,
739
+ ) -> Result<(u16, Value), Error> {
740
+ let inner_borrow = this.inner.borrow();
741
+ let inner = inner_borrow
742
+ .as_ref()
743
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
744
+
745
+ let json_module = ruby
746
+ .class_object()
747
+ .const_get("JSON")
748
+ .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
749
+
750
+ let variables_json = if variables.is_nil() {
751
+ None
752
+ } else {
753
+ Some(ruby_value_to_json(ruby, json_module, variables)?)
754
+ };
755
+
756
+ let operation_name_str = if operation_name.is_nil() {
757
+ None
758
+ } else {
759
+ Some(String::try_convert(operation_name)?)
760
+ };
761
+
762
+ let runtime = crate::server::global_runtime(ruby)?;
763
+ let server = inner.http_server.clone();
764
+ let query_value = query.clone();
765
+
766
+ let snapshot = crate::call_without_gvl!(
767
+ testing::client::block_on_graphql,
768
+ args: (
769
+ runtime, &tokio::runtime::Runtime,
770
+ server, Arc<TestServer>,
771
+ query_value, String,
772
+ variables_json, Option<JsonValue>,
773
+ operation_name_str, Option<String>
774
+ ),
775
+ return_type: Result<ResponseSnapshot, testing::client::NativeRequestError>
776
+ )
777
+ .map_err(|err| {
778
+ Error::new(
779
+ ruby.exception_runtime_error(),
780
+ format!("GraphQL request failed: {}", err.0),
781
+ )
782
+ })?;
783
+
784
+ let status = snapshot.status;
785
+ let response = response_snapshot_to_ruby(ruby, snapshot)?;
786
+ Ok((status, response))
787
+ }
727
788
  }
728
789
 
729
790
  fn websocket_timeout() -> Duration {
@@ -1026,77 +1087,27 @@ impl Handler for RubyHandler {
1026
1087
  }
1027
1088
  }
1028
1089
 
1029
- async fn execute_request(
1030
- server: Arc<TestServer>,
1031
- method: Method,
1032
- path: String,
1033
- config: RequestConfig,
1034
- ) -> Result<TestResponseData, NativeRequestError> {
1035
- let mut request = match method {
1036
- Method::GET => server.get(&path),
1037
- Method::POST => server.post(&path),
1038
- Method::PUT => server.put(&path),
1039
- Method::PATCH => server.patch(&path),
1040
- Method::DELETE => server.delete(&path),
1041
- Method::HEAD => server.method(Method::HEAD, &path),
1042
- Method::OPTIONS => server.method(Method::OPTIONS, &path),
1043
- Method::TRACE => server.method(Method::TRACE, &path),
1044
- other => return Err(NativeRequestError(format!("Unsupported HTTP method {other}"))),
1045
- };
1046
-
1047
- if let Some(query) = config.query {
1048
- request = request.add_query_params(&query);
1049
- }
1090
+ // These functions are now in testing::client module - use call_without_gvl! macro to call them
1050
1091
 
1051
- for (name, value) in config.headers {
1052
- request = request.add_header(name.as_str(), value.as_str());
1053
- }
1092
+ fn response_snapshot_to_ruby(ruby: &Ruby, snapshot: ResponseSnapshot) -> Result<Value, Error> {
1093
+ let hash = ruby.hash_new();
1054
1094
 
1055
- for (name, value) in config.cookies {
1056
- request = request.add_cookie(Cookie::new(name, value));
1057
- }
1095
+ hash.aset(
1096
+ ruby.intern("status_code"),
1097
+ ruby.integer_from_i64(snapshot.status as i64),
1098
+ )?;
1058
1099
 
1059
- if let Some(body) = config.body {
1060
- match body {
1061
- RequestBody::Json(json_value) => {
1062
- request = request.json(&json_value);
1063
- }
1064
- RequestBody::Form(form_value) => {
1065
- let encoded = encode_urlencoded_body(&form_value)
1066
- .map_err(|err| NativeRequestError(format!("Failed to encode form body: {err}")))?;
1067
- request = request
1068
- .content_type("application/x-www-form-urlencoded")
1069
- .bytes(Bytes::from(encoded));
1070
- }
1071
- RequestBody::Raw(raw) => {
1072
- request = request.bytes(Bytes::from(raw));
1073
- }
1074
- RequestBody::Multipart { form_data, files } => {
1075
- let (multipart_body, boundary) = build_multipart_body(&form_data, &files);
1076
- request = request
1077
- .content_type(&format!("multipart/form-data; boundary={}", boundary))
1078
- .bytes(Bytes::from(multipart_body));
1079
- }
1080
- }
1100
+ let headers_hash = ruby.hash_new();
1101
+ for (key, value) in snapshot.headers {
1102
+ headers_hash.aset(ruby.str_new(&key), ruby.str_new(&value))?;
1081
1103
  }
1104
+ hash.aset(ruby.intern("headers"), headers_hash)?;
1082
1105
 
1083
- let response = request.await;
1084
- let snapshot = snapshot_response(response).await.map_err(snapshot_err_to_native)?;
1085
- let body_text = if snapshot.body.is_empty() {
1086
- None
1087
- } else {
1088
- Some(String::from_utf8_lossy(&snapshot.body).into_owned())
1089
- };
1090
-
1091
- Ok(TestResponseData {
1092
- status: snapshot.status,
1093
- headers: snapshot.headers,
1094
- body_text,
1095
- })
1096
- }
1106
+ let body_value = ruby.str_new(&String::from_utf8_lossy(&snapshot.body));
1107
+ hash.aset(ruby.intern("body"), body_value)?;
1108
+ hash.aset(ruby.intern("body_text"), body_value)?;
1097
1109
 
1098
- fn snapshot_err_to_native(err: SnapshotError) -> NativeRequestError {
1099
- NativeRequestError(err.to_string())
1110
+ Ok(hash.as_value())
1100
1111
  }
1101
1112
 
1102
1113
  fn parse_request_config(ruby: &Ruby, options: Value) -> Result<RequestConfig, Error> {
@@ -1377,7 +1388,7 @@ fn is_streaming_response(ruby: &Ruby, value: Value) -> Result<bool, Error> {
1377
1388
  Ok(value.respond_to(stream_sym, false)? && value.respond_to(status_sym, false)?)
1378
1389
  }
1379
1390
 
1380
- fn response_to_ruby(ruby: &Ruby, response: TestResponseData) -> Result<Value, Error> {
1391
+ fn response_to_ruby(ruby: &Ruby, response: testing::client::TestResponseData) -> Result<Value, Error> {
1381
1392
  let hash = ruby.hash_new();
1382
1393
 
1383
1394
  hash.aset(
@@ -1573,6 +1584,8 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
1573
1584
  class.define_method("request", method!(NativeTestClient::request, 3))?;
1574
1585
  class.define_method("websocket", method!(NativeTestClient::websocket, 1))?;
1575
1586
  class.define_method("sse", method!(NativeTestClient::sse, 1))?;
1587
+ class.define_method("graphql", method!(NativeTestClient::graphql, 3))?;
1588
+ class.define_method("graphql_with_status", method!(NativeTestClient::graphql_with_status, 3))?;
1576
1589
  class.define_method("close", method!(NativeTestClient::close, 0))?;
1577
1590
 
1578
1591
  let built_response_class = native.define_class("BuiltResponse", ruby.class_object())?;