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
@@ -7,7 +7,7 @@ use axum::{
7
7
  body::Body,
8
8
  http::{Request, Response, StatusCode},
9
9
  };
10
- use magnus::{RHash, Value, gc::Marker, prelude::*, value::InnerValue, value::Opaque};
10
+ use magnus::{RHash, Value, gc::Marker, prelude::*, r_hash::ForEach, value::InnerValue, value::Opaque};
11
11
  use serde_json::Value as JsonValue;
12
12
  use spikard_http::lifecycle::{HookResult, LifecycleHook};
13
13
  use std::future::Future;
@@ -253,6 +253,34 @@ impl LifecycleHook<Request<Body>, Response<Body>> for RubyLifecycleHook {
253
253
  Response::builder().status(StatusCode::from_u16(status as u16).unwrap_or(StatusCode::OK));
254
254
 
255
255
  response_builder = response_builder.header("content-type", "application/json");
256
+ if let Some(headers_hash) =
257
+ result_hash.get(ruby.to_symbol("headers")).and_then(RHash::from_value)
258
+ {
259
+ let mut header_pairs: Vec<(String, String)> = Vec::new();
260
+ headers_hash
261
+ .foreach(|key: Value, val: Value| {
262
+ let header_name = String::try_convert(key).unwrap_or_else(|_| {
263
+ key.to_r_string()
264
+ .and_then(|s| s.to_string())
265
+ .unwrap_or_else(|_| String::new())
266
+ });
267
+ let header_value = String::try_convert(val).unwrap_or_else(|_| {
268
+ val.to_r_string()
269
+ .and_then(|s| s.to_string())
270
+ .unwrap_or_else(|_| String::new())
271
+ });
272
+
273
+ if !header_name.is_empty() {
274
+ header_pairs.push((header_name, header_value));
275
+ }
276
+ Ok(ForEach::Continue)
277
+ })
278
+ .map_err(|e| format!("Failed to set headers: {}", e))?;
279
+
280
+ for (header_name, header_value) in header_pairs {
281
+ response_builder = response_builder.header(header_name, header_value);
282
+ }
283
+ }
256
284
 
257
285
  let response = response_builder
258
286
  .body(Body::from(body_str))
@@ -33,7 +33,7 @@ pub fn build_route_metadata(
33
33
  .const_get("JSON")
34
34
  .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
35
35
 
36
- let request_schema = if request_schema_value.is_nil() {
36
+ let mut request_schema = if request_schema_value.is_nil() {
37
37
  None
38
38
  } else {
39
39
  Some(ruby_value_to_json(ruby, json_module, request_schema_value)?)
@@ -43,7 +43,7 @@ pub fn build_route_metadata(
43
43
  } else {
44
44
  Some(ruby_value_to_json(ruby, json_module, response_schema_value)?)
45
45
  };
46
- let parameter_schema = if parameter_schema_value.is_nil() {
46
+ let mut parameter_schema = if parameter_schema_value.is_nil() {
47
47
  None
48
48
  } else {
49
49
  Some(ruby_value_to_json(ruby, json_module, parameter_schema_value)?)
@@ -54,6 +54,12 @@ pub fn build_route_metadata(
54
54
  Some(ruby_value_to_json(ruby, json_module, file_params_value)?)
55
55
  };
56
56
 
57
+ if parameter_schema.is_none()
58
+ && let Some(derived) = derive_parameter_schema_from_request(&mut request_schema)
59
+ {
60
+ parameter_schema = Some(derived);
61
+ }
62
+
57
63
  let cors = parse_cors_config(ruby, cors_value)?;
58
64
  let handler_dependencies = extract_handler_dependencies_from_ruby(ruby, handler_value)?;
59
65
 
@@ -70,6 +76,7 @@ pub fn build_route_metadata(
70
76
  Some(ruby_value_to_json(ruby, json_module, jsonrpc_method_value)?)
71
77
  };
72
78
 
79
+ #[cfg(feature = "di")]
73
80
  let mut metadata = RouteMetadata {
74
81
  method,
75
82
  path: normalized_path,
@@ -81,9 +88,25 @@ pub fn build_route_metadata(
81
88
  is_async,
82
89
  cors,
83
90
  body_param_name,
84
- #[cfg(feature = "di")]
85
91
  handler_dependencies: handler_deps_option,
86
92
  jsonrpc_method,
93
+ static_response: None,
94
+ };
95
+
96
+ #[cfg(not(feature = "di"))]
97
+ let mut metadata = RouteMetadata {
98
+ method,
99
+ path: normalized_path,
100
+ handler_name: final_handler_name,
101
+ request_schema,
102
+ response_schema,
103
+ parameter_schema,
104
+ file_params,
105
+ is_async,
106
+ cors,
107
+ body_param_name,
108
+ jsonrpc_method,
109
+ static_response: None,
87
110
  };
88
111
 
89
112
  let registry = SchemaRegistry::new();
@@ -101,6 +124,78 @@ pub fn build_route_metadata(
101
124
  route_metadata_to_ruby(ruby, &metadata)
102
125
  }
103
126
 
127
+ fn derive_parameter_schema_from_request(request_schema: &mut Option<JsonValue>) -> Option<JsonValue> {
128
+ let schema = request_schema.as_ref()?;
129
+ let schema_obj = schema.as_object()?;
130
+ let properties = schema_obj.get("properties")?.as_object()?;
131
+
132
+ let mut param_properties = JsonMap::new();
133
+ let mut required = Vec::new();
134
+ let mut has_params = false;
135
+
136
+ let sources = [
137
+ ("path", "path"),
138
+ ("query", "query"),
139
+ ("headers", "header"),
140
+ ("cookies", "cookie"),
141
+ ];
142
+
143
+ for (section_key, source) in sources {
144
+ let Some(section_schema) = properties.get(section_key) else {
145
+ continue;
146
+ };
147
+ let Some(section_props) = section_schema.get("properties").and_then(|value| value.as_object()) else {
148
+ continue;
149
+ };
150
+
151
+ has_params = true;
152
+ for (name, schema_value) in section_props {
153
+ let mut schema_obj = if let Some(obj) = schema_value.as_object() {
154
+ obj.clone()
155
+ } else {
156
+ let mut wrapped = JsonMap::new();
157
+ wrapped.insert("const".to_string(), schema_value.clone());
158
+ wrapped
159
+ };
160
+ schema_obj.insert("source".to_string(), JsonValue::String(source.to_string()));
161
+ param_properties.insert(name.clone(), JsonValue::Object(schema_obj));
162
+ }
163
+
164
+ if let Some(required_list) = section_schema.get("required").and_then(|value| value.as_array()) {
165
+ for item in required_list {
166
+ if let Some(name) = item.as_str() {
167
+ required.push(name.to_string());
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ if !has_params {
174
+ return None;
175
+ }
176
+
177
+ let mut derived = JsonMap::new();
178
+ derived.insert("type".to_string(), JsonValue::String("object".to_string()));
179
+ derived.insert("properties".to_string(), JsonValue::Object(param_properties));
180
+
181
+ if !required.is_empty() {
182
+ required.sort();
183
+ required.dedup();
184
+ derived.insert(
185
+ "required".to_string(),
186
+ JsonValue::Array(required.into_iter().map(JsonValue::String).collect()),
187
+ );
188
+ }
189
+
190
+ if let Some(body_schema) = properties.get("body") {
191
+ *request_schema = Some(body_schema.clone());
192
+ } else {
193
+ *request_schema = None;
194
+ }
195
+
196
+ Some(JsonValue::Object(derived))
197
+ }
198
+
104
199
  /// Convert a RouteMetadata to a Ruby hash
105
200
  pub fn route_metadata_to_ruby(ruby: &Ruby, metadata: &RouteMetadata) -> Result<Value, Error> {
106
201
  let hash = ruby.hash_new();
@@ -117,19 +212,19 @@ pub fn route_metadata_to_ruby(ruby: &Ruby, metadata: &RouteMetadata) -> Result<V
117
212
 
118
213
  hash.aset(
119
214
  ruby.to_symbol("request_schema"),
120
- option_json_to_ruby(ruby, &metadata.request_schema)?,
215
+ option_json_to_ruby(ruby, metadata.request_schema.as_ref())?,
121
216
  )?;
122
217
  hash.aset(
123
218
  ruby.to_symbol("response_schema"),
124
- option_json_to_ruby(ruby, &metadata.response_schema)?,
219
+ option_json_to_ruby(ruby, metadata.response_schema.as_ref())?,
125
220
  )?;
126
221
  hash.aset(
127
222
  ruby.to_symbol("parameter_schema"),
128
- option_json_to_ruby(ruby, &metadata.parameter_schema)?,
223
+ option_json_to_ruby(ruby, metadata.parameter_schema.as_ref())?,
129
224
  )?;
130
225
  hash.aset(
131
226
  ruby.to_symbol("file_params"),
132
- option_json_to_ruby(ruby, &metadata.file_params)?,
227
+ option_json_to_ruby(ruby, metadata.file_params.as_ref())?,
133
228
  )?;
134
229
  hash.aset(
135
230
  ruby.to_symbol("body_param_name"),
@@ -140,7 +235,7 @@ pub fn route_metadata_to_ruby(ruby: &Ruby, metadata: &RouteMetadata) -> Result<V
140
235
  .unwrap_or_else(|| ruby.qnil().as_value()),
141
236
  )?;
142
237
 
143
- hash.aset(ruby.to_symbol("cors"), cors_to_ruby(ruby, &metadata.cors)?)?;
238
+ hash.aset(ruby.to_symbol("cors"), cors_to_ruby(ruby, metadata.cors.as_ref())?)?;
144
239
 
145
240
  #[cfg(feature = "di")]
146
241
  {
@@ -157,7 +252,7 @@ pub fn route_metadata_to_ruby(ruby: &Ruby, metadata: &RouteMetadata) -> Result<V
157
252
 
158
253
  hash.aset(
159
254
  ruby.to_symbol("jsonrpc_method"),
160
- option_json_to_ruby(ruby, &metadata.jsonrpc_method)?,
255
+ option_json_to_ruby(ruby, metadata.jsonrpc_method.as_ref())?,
161
256
  )?;
162
257
 
163
258
  Ok(hash.as_value())
@@ -262,11 +357,12 @@ pub fn parse_cors_config(ruby: &Ruby, value: Value) -> Result<Option<spikard_htt
262
357
  expose_headers,
263
358
  max_age,
264
359
  allow_credentials,
360
+ ..Default::default()
265
361
  }))
266
362
  }
267
363
 
268
364
  /// Convert an optional JSON value to Ruby
269
- pub fn option_json_to_ruby(ruby: &Ruby, value: &Option<JsonValue>) -> Result<Value, Error> {
365
+ pub fn option_json_to_ruby(ruby: &Ruby, value: Option<&JsonValue>) -> Result<Value, Error> {
270
366
  if let Some(json) = value {
271
367
  json_to_ruby(ruby, json)
272
368
  } else {
@@ -275,7 +371,7 @@ pub fn option_json_to_ruby(ruby: &Ruby, value: &Option<JsonValue>) -> Result<Val
275
371
  }
276
372
 
277
373
  /// Convert CORS config to Ruby hash
278
- pub fn cors_to_ruby(ruby: &Ruby, cors: &Option<spikard_http::CorsConfig>) -> Result<Value, Error> {
374
+ pub fn cors_to_ruby(ruby: &Ruby, cors: Option<&spikard_http::CorsConfig>) -> Result<Value, Error> {
279
375
  if let Some(cors_config) = cors {
280
376
  let hash = ruby.hash_new();
281
377
  let origins = cors_config
@@ -407,36 +503,5 @@ pub fn ruby_value_to_json(ruby: &Ruby, _json_module: Value, value: Value) -> Res
407
503
 
408
504
  /// Convert JSON to Ruby value
409
505
  pub fn json_to_ruby(ruby: &Ruby, value: &JsonValue) -> Result<Value, Error> {
410
- match value {
411
- JsonValue::Null => Ok(ruby.qnil().as_value()),
412
- JsonValue::Bool(b) => Ok(if *b {
413
- ruby.qtrue().as_value()
414
- } else {
415
- ruby.qfalse().as_value()
416
- }),
417
- JsonValue::Number(num) => {
418
- if let Some(i) = num.as_i64() {
419
- Ok(ruby.integer_from_i64(i).as_value())
420
- } else if let Some(f) = num.as_f64() {
421
- Ok(ruby.float_from_f64(f).as_value())
422
- } else {
423
- Ok(ruby.qnil().as_value())
424
- }
425
- }
426
- JsonValue::String(str_val) => Ok(ruby.str_new(str_val).as_value()),
427
- JsonValue::Array(items) => {
428
- let array = ruby.ary_new();
429
- for item in items {
430
- array.push(json_to_ruby(ruby, item)?)?;
431
- }
432
- Ok(array.as_value())
433
- }
434
- JsonValue::Object(map) => {
435
- let hash = ruby.hash_new();
436
- for (key, item) in map {
437
- hash.aset(ruby.str_new(key), json_to_ruby(ruby, item)?)?;
438
- }
439
- Ok(hash.as_value())
440
- }
441
- }
506
+ crate::conversion::json_to_ruby(ruby, value)
442
507
  }
@@ -13,13 +13,13 @@ use magnus::value::InnerValue;
13
13
  use magnus::value::LazyId;
14
14
  use magnus::value::Opaque;
15
15
  use magnus::{Error, RHash, RString, Ruby, Symbol, Value, gc::Marker};
16
- use serde_json::Value as JsonValue;
16
+ use serde_json::{Map as JsonMap, Value as JsonValue};
17
17
  use spikard_http::RequestData;
18
18
  use std::cell::RefCell;
19
19
  use std::collections::HashMap;
20
20
  use std::sync::Arc;
21
21
 
22
- use crate::conversion::{map_to_ruby_hash, multimap_to_ruby_hash};
22
+ use crate::conversion::{json_to_ruby_with_uploads, map_to_ruby_hash, multimap_to_ruby_hash};
23
23
  use crate::metadata::json_to_ruby;
24
24
 
25
25
  #[derive(Default)]
@@ -38,7 +38,7 @@ struct RequestCache {
38
38
  }
39
39
 
40
40
  #[magnus::wrap(class = "Spikard::Native::Request", free_immediately, mark)]
41
- pub(crate) struct NativeRequest {
41
+ pub struct NativeRequest {
42
42
  method: String,
43
43
  path: String,
44
44
  path_params: Arc<HashMap<String, String>>,
@@ -49,6 +49,9 @@ pub(crate) struct NativeRequest {
49
49
  headers: Arc<HashMap<String, String>>,
50
50
  cookies: Arc<HashMap<String, String>>,
51
51
  validated_params: Option<JsonValue>,
52
+ /// Upload file class for wrapping file upload objects in the body.
53
+ /// When present, `json_to_ruby_with_uploads` is used instead of `json_to_ruby`.
54
+ upload_file_class: Option<Opaque<Value>>,
52
55
  cache: RefCell<RequestCache>,
53
56
  }
54
57
 
@@ -64,7 +67,51 @@ static KEY_RAW_BODY: LazyId = LazyId::new("raw_body");
64
67
  static KEY_PARAMS: LazyId = LazyId::new("params");
65
68
 
66
69
  impl NativeRequest {
67
- pub(crate) fn from_request_data(request_data: RequestData, validated_params: Option<JsonValue>) -> Self {
70
+ /// Convert RequestData to NativeRequest with Arc unwrapping for lazy cache.
71
+ ///
72
+ /// # Arc Unwrapping Strategy
73
+ ///
74
+ /// `spikard_http::RequestData` has Arc-wrapped fields for cheap cloning:
75
+ /// - `query_params: Arc<Value>`
76
+ /// - `body: Arc<Value>`
77
+ /// - `validated_params: Option<Arc<Value>>`
78
+ ///
79
+ /// This method unwraps these Arc fields into plain Values for storage in NativeRequest,
80
+ /// using `Arc::try_unwrap()` to eliminate the clone when the Arc has a unique reference.
81
+ ///
82
+ /// ## Pattern: Arc::try_unwrap Optimization
83
+ ///
84
+ /// ```text
85
+ /// Arc::try_unwrap(arc)
86
+ /// → Ok(Value) if Arc has unique ref (no other clones)
87
+ /// → Err(Arc) if Arc has multiple refs
88
+ ///
89
+ /// Result: eliminates guaranteed clone ~95% of time (single request flow)
90
+ /// Fallback: clones only when Arc is shared (rare case)
91
+ /// ```
92
+ ///
93
+ /// ## Why This Works with Lazy Caching
94
+ ///
95
+ /// The lazy cache pattern in this struct caches converted Ruby values, not the original JSON.
96
+ /// Once unwrapped here, the Arc-wrapped Values are never unwrapped again:
97
+ ///
98
+ /// 1. `RequestData` arrives with Arc-wrapped JSON (from HTTP layer)
99
+ /// 2. `from_request_data()` unwraps Arc → stores plain JsonValue
100
+ /// 3. Cache stores converted Ruby values (not JSON)
101
+ /// 4. No further Arc operations needed in cache methods
102
+ ///
103
+ /// This is a **one-time operation per request**, not repeated per field access.
104
+ ///
105
+ /// ## Performance Impact
106
+ ///
107
+ /// - Typical: 5-10% faster (eliminates clone for ~95% of requests)
108
+ /// - Worst case: Same as clone (if Arc is shared, which rarely happens)
109
+ /// - Best case: Pure move, zero copy (Arc has unique reference)
110
+ pub(crate) fn from_request_data(
111
+ request_data: RequestData,
112
+ validated_params: Option<JsonValue>,
113
+ upload_file_class: Option<Opaque<Value>>,
114
+ ) -> Self {
68
115
  let RequestData {
69
116
  path_params,
70
117
  query_params,
@@ -82,19 +129,22 @@ impl NativeRequest {
82
129
  method,
83
130
  path,
84
131
  path_params,
85
- query_params,
132
+ // Arc::try_unwrap eliminates clone when possible (most requests have unique Arc ref)
133
+ query_params: Arc::try_unwrap(query_params).unwrap_or_else(|arc| (*arc).clone()),
86
134
  raw_query_params,
87
- body,
135
+ // Arc::try_unwrap eliminates clone when possible (most requests have unique Arc ref)
136
+ body: Arc::try_unwrap(body).unwrap_or_else(|arc| (*arc).clone()),
88
137
  raw_body,
89
138
  headers,
90
139
  cookies,
91
140
  validated_params,
141
+ upload_file_class,
92
142
  cache: RefCell::new(RequestCache::default()),
93
143
  }
94
144
  }
95
145
 
96
- fn cache_get(cache: &Option<Opaque<Value>>, ruby: &Ruby) -> Option<Value> {
97
- cache.as_ref().map(|v| v.get_inner_with(ruby))
146
+ fn cache_get(cache: Option<&Opaque<Value>>, ruby: &Ruby) -> Option<Value> {
147
+ cache.map(|v| v.get_inner_with(ruby))
98
148
  }
99
149
 
100
150
  fn cache_set(slot: &mut Option<Opaque<Value>>, value: Value) -> Value {
@@ -105,7 +155,7 @@ impl NativeRequest {
105
155
  pub(crate) fn method(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
106
156
  if let Some(value) = {
107
157
  let cache = this.cache.borrow();
108
- Self::cache_get(&cache.method, ruby)
158
+ Self::cache_get(cache.method.as_ref(), ruby)
109
159
  } {
110
160
  return Ok(value);
111
161
  }
@@ -117,7 +167,7 @@ impl NativeRequest {
117
167
  pub(crate) fn path(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
118
168
  if let Some(value) = {
119
169
  let cache = this.cache.borrow();
120
- Self::cache_get(&cache.path, ruby)
170
+ Self::cache_get(cache.path.as_ref(), ruby)
121
171
  } {
122
172
  return Ok(value);
123
173
  }
@@ -129,10 +179,25 @@ impl NativeRequest {
129
179
  pub(crate) fn path_params(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
130
180
  if let Some(cached) = {
131
181
  let cache = this.cache.borrow();
132
- Self::cache_get(&cache.path_params, ruby)
182
+ Self::cache_get(cache.path_params.as_ref(), ruby)
133
183
  } {
134
184
  return Ok(cached);
135
185
  }
186
+ if let Some(validated) = &this.validated_params
187
+ && let Some(validated_map) = validated.as_object()
188
+ {
189
+ let mut subset = JsonMap::new();
190
+ for key in this.path_params.keys() {
191
+ if let Some(value) = validated_map.get(key) {
192
+ subset.insert(key.clone(), value.clone());
193
+ }
194
+ }
195
+ if !subset.is_empty() {
196
+ let value = json_to_ruby(ruby, &JsonValue::Object(subset))?;
197
+ let mut cache = this.cache.borrow_mut();
198
+ return Ok(Self::cache_set(&mut cache.path_params, value));
199
+ }
200
+ }
136
201
  let value = map_to_ruby_hash(ruby, this.path_params.as_ref())?;
137
202
  let mut cache = this.cache.borrow_mut();
138
203
  Ok(Self::cache_set(&mut cache.path_params, value))
@@ -141,10 +206,34 @@ impl NativeRequest {
141
206
  pub(crate) fn query(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
142
207
  if let Some(cached) = {
143
208
  let cache = this.cache.borrow();
144
- Self::cache_get(&cache.query, ruby)
209
+ Self::cache_get(cache.query.as_ref(), ruby)
145
210
  } {
146
211
  return Ok(cached);
147
212
  }
213
+ if let Some(validated) = &this.validated_params
214
+ && let Some(validated_map) = validated.as_object()
215
+ {
216
+ let mut subset = JsonMap::new();
217
+ if !this.raw_query_params.is_empty() {
218
+ for key in this.raw_query_params.keys() {
219
+ if let Some(value) = validated_map.get(key) {
220
+ subset.insert(key.clone(), value.clone());
221
+ }
222
+ }
223
+ } else if let Some(query_map) = this.query_params.as_object() {
224
+ for key in query_map.keys() {
225
+ if let Some(value) = validated_map.get(key) {
226
+ subset.insert(key.clone(), value.clone());
227
+ }
228
+ }
229
+ }
230
+
231
+ if !subset.is_empty() {
232
+ let value = json_to_ruby(ruby, &JsonValue::Object(subset))?;
233
+ let mut cache = this.cache.borrow_mut();
234
+ return Ok(Self::cache_set(&mut cache.query, value));
235
+ }
236
+ }
148
237
  let value = json_to_ruby(ruby, &this.query_params)?;
149
238
  let mut cache = this.cache.borrow_mut();
150
239
  Ok(Self::cache_set(&mut cache.query, value))
@@ -153,7 +242,7 @@ impl NativeRequest {
153
242
  pub(crate) fn raw_query(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
154
243
  if let Some(cached) = {
155
244
  let cache = this.cache.borrow();
156
- Self::cache_get(&cache.raw_query, ruby)
245
+ Self::cache_get(cache.raw_query.as_ref(), ruby)
157
246
  } {
158
247
  return Ok(cached);
159
248
  }
@@ -165,7 +254,7 @@ impl NativeRequest {
165
254
  pub(crate) fn headers(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
166
255
  if let Some(cached) = {
167
256
  let cache = this.cache.borrow();
168
- Self::cache_get(&cache.headers, ruby)
257
+ Self::cache_get(cache.headers.as_ref(), ruby)
169
258
  } {
170
259
  return Ok(cached);
171
260
  }
@@ -177,7 +266,7 @@ impl NativeRequest {
177
266
  pub(crate) fn cookies(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
178
267
  if let Some(cached) = {
179
268
  let cache = this.cache.borrow();
180
- Self::cache_get(&cache.cookies, ruby)
269
+ Self::cache_get(cache.cookies.as_ref(), ruby)
181
270
  } {
182
271
  return Ok(cached);
183
272
  }
@@ -189,11 +278,12 @@ impl NativeRequest {
189
278
  pub(crate) fn body(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
190
279
  if let Some(cached) = {
191
280
  let cache = this.cache.borrow();
192
- Self::cache_get(&cache.body, ruby)
281
+ Self::cache_get(cache.body.as_ref(), ruby)
193
282
  } {
194
283
  return Ok(cached);
195
284
  }
196
- let value = json_to_ruby(ruby, &this.body)?;
285
+ let upload_cls = this.upload_file_class.as_ref().map(|o| o.get_inner_with(ruby));
286
+ let value = json_to_ruby_with_uploads(ruby, &this.body, upload_cls.as_ref())?;
197
287
  let mut cache = this.cache.borrow_mut();
198
288
  Ok(Self::cache_set(&mut cache.body, value))
199
289
  }
@@ -201,7 +291,7 @@ impl NativeRequest {
201
291
  pub(crate) fn raw_body(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
202
292
  if let Some(cached) = {
203
293
  let cache = this.cache.borrow();
204
- Self::cache_get(&cache.raw_body, ruby)
294
+ Self::cache_get(cache.raw_body.as_ref(), ruby)
205
295
  } {
206
296
  return Ok(cached);
207
297
  }
@@ -216,7 +306,7 @@ impl NativeRequest {
216
306
  pub(crate) fn params(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
217
307
  if let Some(value) = {
218
308
  let cache = this.cache.borrow();
219
- Self::cache_get(&cache.params, ruby)
309
+ Self::cache_get(cache.params.as_ref(), ruby)
220
310
  } {
221
311
  return Ok(value);
222
312
  }
@@ -247,7 +337,7 @@ impl NativeRequest {
247
337
  pub(crate) fn to_h(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
248
338
  if let Some(value) = {
249
339
  let cache = this.cache.borrow();
250
- Self::cache_get(&cache.to_h, ruby)
340
+ Self::cache_get(cache.to_h.as_ref(), ruby)
251
341
  } {
252
342
  return Ok(value);
253
343
  }
@@ -296,6 +386,9 @@ impl NativeRequest {
296
386
  }
297
387
 
298
388
  if let Ok(text) = RString::try_convert(key) {
389
+ // SAFETY: We only borrow the slice for the duration of this match
390
+ // block and never mutate the RString. The GVL is held, so no other
391
+ // Ruby thread can modify or move the string's backing memory.
299
392
  let slice = unsafe { text.as_slice() };
300
393
  return match slice {
301
394
  b"method" => Self::method(ruby, this),
@@ -337,6 +430,10 @@ impl NativeRequest {
337
430
  {
338
431
  marker.mark(handle.get_inner_with(&ruby));
339
432
  }
433
+
434
+ if let Some(ref cls) = self.upload_file_class {
435
+ marker.mark(cls.get_inner_with(&ruby));
436
+ }
340
437
  }
341
438
  }
342
439
  }
@@ -11,8 +11,49 @@ use magnus::prelude::*;
11
11
  use magnus::{Error, RHash, Ruby, TryConvert, Value, r_hash::ForEach};
12
12
  use spikard_http::{Handler, Route, RouteMetadata, SchemaRegistry, Server};
13
13
  use std::sync::Arc;
14
+ use tokio::runtime::Runtime;
14
15
  use tracing::{info, warn};
15
16
 
17
+ /// Helper function to run the server startup logic without the GVL.
18
+ ///
19
+ /// This is called via `call_without_gvl!` to release the GVL before blocking on the async runtime.
20
+ /// This allows handlers to acquire the GVL during request processing.
21
+ async fn start_server_async(
22
+ socket_addr: std::net::SocketAddr,
23
+ app_router: axum::Router,
24
+ background_config: spikard_http::BackgroundTaskConfig,
25
+ ) -> Result<(), String> {
26
+ let listener = tokio::net::TcpListener::bind(socket_addr)
27
+ .await
28
+ .map_err(|err| format!("Failed to bind to {socket_addr}: {err}"))?;
29
+
30
+ info!("Server listening on {}", socket_addr);
31
+
32
+ let background_runtime = spikard_http::BackgroundRuntime::start(background_config).await;
33
+ crate::background::install_handle(background_runtime.handle());
34
+
35
+ let serve_result = axum::serve(listener, app_router).await;
36
+
37
+ crate::background::clear_handle();
38
+
39
+ if let Err(err) = background_runtime.shutdown().await {
40
+ warn!("Failed to drain background tasks during shutdown: {:?}", err);
41
+ }
42
+
43
+ serve_result.map_err(|e| format!("Server error: {e}"))?;
44
+ Ok::<(), String>(())
45
+ }
46
+
47
+ /// Wrapper function for `call_without_gvl!` to start the server without the GVL.
48
+ fn start_server_without_gvl(
49
+ runtime: &Runtime,
50
+ socket_addr: std::net::SocketAddr,
51
+ app_router: axum::Router,
52
+ background_config: spikard_http::BackgroundTaskConfig,
53
+ ) -> Result<(), String> {
54
+ runtime.block_on(start_server_async(socket_addr, app_router, background_config))
55
+ }
56
+
16
57
  /// Start the Spikard HTTP server from Ruby
17
58
  ///
18
59
  /// Creates an Axum HTTP server in a dedicated background thread with its own Tokio runtime.
@@ -197,9 +238,9 @@ pub fn run_server(
197
238
  .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
198
239
 
199
240
  ws_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
200
- let ws_state = crate::websocket::create_websocket_state(ruby, factory)?;
201
-
202
- ws_endpoints.push((path, ws_state));
241
+ if let Some(ws_state) = crate::websocket::create_websocket_state(ruby, factory)? {
242
+ ws_endpoints.push((path, ws_state));
243
+ }
203
244
 
204
245
  Ok(ForEach::Continue)
205
246
  })?;
@@ -261,30 +302,16 @@ pub fn run_server(
261
302
  })?;
262
303
 
263
304
  let background_config = config.background_tasks.clone();
305
+ let runtime_ref = &runtime;
264
306
 
265
- runtime
266
- .block_on(async move {
267
- let listener = tokio::net::TcpListener::bind(socket_addr)
268
- .await
269
- .map_err(|err| format!("Failed to bind to {socket_addr}: {err}"))?;
270
-
271
- info!("Server listening on {}", socket_addr);
272
-
273
- let background_runtime = spikard_http::BackgroundRuntime::start(background_config.clone()).await;
274
- crate::background::install_handle(background_runtime.handle());
275
-
276
- let serve_result = axum::serve(listener, app_router).await;
277
-
278
- crate::background::clear_handle();
279
-
280
- if let Err(err) = background_runtime.shutdown().await {
281
- warn!("Failed to drain background tasks during shutdown: {:?}", err);
282
- }
307
+ // Release the GVL before blocking on the async runtime to allow handlers to acquire it during request processing
308
+ let result = crate::call_without_gvl!(
309
+ start_server_without_gvl,
310
+ args: (runtime_ref, &Runtime, socket_addr, std::net::SocketAddr, app_router, axum::Router, background_config, spikard_http::BackgroundTaskConfig),
311
+ return_type: Result<(), String>
312
+ );
283
313
 
284
- serve_result.map_err(|e| format!("Server error: {e}"))?;
285
- Ok::<(), String>(())
286
- })
287
- .map_err(|msg| Error::new(ruby.exception_runtime_error(), msg))?;
314
+ result.map_err(|msg| Error::new(ruby.exception_runtime_error(), msg))?;
288
315
 
289
316
  Ok(())
290
317
  }