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
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
97
|
-
cache.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
281
|
+
Self::cache_get(cache.body.as_ref(), ruby)
|
|
193
282
|
} {
|
|
194
283
|
return Ok(cached);
|
|
195
284
|
}
|
|
196
|
-
let
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
}
|