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
@@ -1,9 +1,13 @@
1
1
  //! Ruby gRPC bindings for Spikard
2
2
  //!
3
- //! This module provides a bridge between Ruby code and Spikard's gRPC runtime,
4
- //! allowing Ruby handlers to process gRPC requests using protobuf serialization.
3
+ //! This module provides Ruby gRPC handler integration with full streaming support:
4
+ //! - Unary RPCs (single request, single response)
5
+ //! - Server streaming RPCs (single request, stream of responses)
6
+ //! - Client streaming RPCs (stream of requests, single response)
7
+ //! - Bidirectional streaming RPCs (stream of requests, stream of responses)
5
8
 
6
9
  pub mod handler;
7
10
 
11
+ // Re-export types for when the API is integrated
8
12
  #[allow(unused_imports)]
9
13
  pub use handler::{RubyGrpcHandler, RubyGrpcRequest, RubyGrpcResponse};
@@ -37,7 +37,7 @@ where
37
37
  };
38
38
 
39
39
  unsafe {
40
- rb_sys::rb_thread_call_with_gvl(Some(trampoline::<F, R>), &mut data as *mut _ as *mut c_void);
40
+ rb_sys::rb_thread_call_with_gvl(Some(trampoline::<F, R>), &raw mut data as *mut c_void);
41
41
  data.result.assume_init()
42
42
  }
43
43
  }
@@ -50,7 +50,7 @@ macro_rules! call_without_gvl {
50
50
  // Box the arguments to ensure they live on the heap for the entire duration of the FFI call
51
51
  let data = std::boxed::Box::new((
52
52
  ($($arg,)+),
53
- &mut result as *mut std::mem::MaybeUninit<$return_ty>,
53
+ &raw mut result as *mut std::mem::MaybeUninit<$return_ty>,
54
54
  ));
55
55
  let data_ptr = std::boxed::Box::into_raw(data) as *mut std::ffi::c_void;
56
56
 
@@ -22,21 +22,13 @@ use std::panic::AssertUnwindSafe;
22
22
  use std::pin::Pin;
23
23
  use std::sync::Arc;
24
24
 
25
- use crate::conversion::{
26
- json_to_ruby, json_to_ruby_with_uploads, map_to_ruby_hash, multimap_to_ruby_hash, ruby_value_to_json,
27
- };
25
+ use crate::conversion::ruby_value_to_json;
28
26
  use crate::gvl::with_gvl;
27
+ use crate::request::NativeRequest;
29
28
 
30
- static KEY_METHOD: LazyId = LazyId::new("method");
31
- static KEY_PATH: LazyId = LazyId::new("path");
32
29
  static KEY_PATH_PARAMS: LazyId = LazyId::new("path_params");
33
30
  static KEY_QUERY: LazyId = LazyId::new("query");
34
- static KEY_RAW_QUERY: LazyId = LazyId::new("raw_query");
35
- static KEY_HEADERS: LazyId = LazyId::new("headers");
36
- static KEY_COOKIES: LazyId = LazyId::new("cookies");
37
31
  static KEY_BODY: LazyId = LazyId::new("body");
38
- static KEY_RAW_BODY: LazyId = LazyId::new("raw_body");
39
- static KEY_PARAMS: LazyId = LazyId::new("params");
40
32
 
41
33
  /// Response payload with status, headers, and body data.
42
34
  pub struct HandlerResponsePayload {
@@ -170,6 +162,7 @@ impl RubyHandler {
170
162
  "Ruby VM unavailable while creating handler",
171
163
  ));
172
164
  };
165
+ let handler_value = crate::conversion::ensure_callable(&ruby, handler_value, &route.handler_name)?;
173
166
  let method_value = Opaque::from(ruby.str_new(&method).as_value());
174
167
  let path_value = Opaque::from(ruby.str_new(&path).as_value());
175
168
 
@@ -192,7 +185,7 @@ impl RubyHandler {
192
185
  ///
193
186
  /// This is used by run_server to create handlers from Ruby Procs
194
187
  pub fn new_for_server(
195
- _ruby: &Ruby,
188
+ ruby: &Ruby,
196
189
  handler_value: Value,
197
190
  handler_name: String,
198
191
  method: String,
@@ -205,12 +198,7 @@ impl RubyHandler {
205
198
  } else {
206
199
  None
207
200
  };
208
- let Ok(ruby) = Ruby::get() else {
209
- return Err(Error::new(
210
- magnus::exception::runtime_error(),
211
- "Ruby VM unavailable while creating handler",
212
- ));
213
- };
201
+ let handler_value = crate::conversion::ensure_callable(ruby, handler_value, &handler_name)?;
214
202
  let method_value = Opaque::from(ruby.str_new(&method).as_value());
215
203
  let path_value = Opaque::from(ruby.str_new(&path).as_value());
216
204
 
@@ -255,9 +243,7 @@ impl RubyHandler {
255
243
  })
256
244
  }
257
245
 
258
- fn handle_inner(&self, request_data: RequestData) -> HandlerResult {
259
- let validated_params = request_data.validated_params.clone();
260
-
246
+ fn handle_inner(&self, mut request_data: RequestData) -> HandlerResult {
261
247
  let ruby = Ruby::get().map_err(|_| {
262
248
  ErrorResponseBuilder::structured_error(
263
249
  StatusCode::INTERNAL_SERVER_ERROR,
@@ -266,20 +252,22 @@ impl RubyHandler {
266
252
  )
267
253
  })?;
268
254
 
269
- let request_value = build_ruby_request(&ruby, &self.inner, &request_data, validated_params.as_ref())
270
- .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
255
+ // Extract validated_params with Arc::try_unwrap to eliminate clone if possible.
256
+ let validated_params = request_data
257
+ .validated_params
258
+ .take()
259
+ .map(|arc| Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone()));
260
+
261
+ // Use NativeRequest for lazy field conversion — fields are only converted
262
+ // to Ruby objects when accessed, avoiding work for unused fields.
263
+ let native_request =
264
+ NativeRequest::from_request_data(request_data, validated_params, self.inner.upload_file_class);
265
+ let request_value = ruby.obj_wrap(native_request).as_value();
271
266
 
272
267
  let handler_proc = self.inner.handler_proc.get_inner_with(&ruby);
273
- let handler_result = handler_proc.funcall("call", (request_value,));
274
- let response_value = match handler_result {
268
+ let response_value = match call_handler_proc(&ruby, handler_proc, request_value) {
275
269
  Ok(value) => value,
276
- Err(err) => {
277
- return Err(ErrorResponseBuilder::structured_error(
278
- StatusCode::INTERNAL_SERVER_ERROR,
279
- "handler_failed",
280
- format!("Handler '{}' failed: {}", self.inner.handler_name, err),
281
- ));
282
- }
270
+ Err(err) => return Err(problem_from_ruby_error(&ruby, &self.inner, err)),
283
271
  };
284
272
 
285
273
  let handler_result = interpret_handler_response(&ruby, &self.inner, response_value).map_err(|err| {
@@ -310,7 +298,7 @@ impl RubyHandler {
310
298
  if let Some(validator) = &self.inner.response_validator {
311
299
  let candidate_body = match payload.body.clone() {
312
300
  Some(body) => Some(body),
313
- None => match try_parse_raw_body(&payload.raw_body) {
301
+ None => match try_parse_raw_body(payload.raw_body.as_ref()) {
314
302
  Ok(parsed) => parsed,
315
303
  Err(err) => {
316
304
  return Err(ErrorResponseBuilder::structured_error(
@@ -409,15 +397,16 @@ impl Handler for RubyHandler {
409
397
  }
410
398
  }
411
399
 
412
- fn try_parse_raw_body(raw_body: &Option<Vec<u8>>) -> Result<Option<JsonValue>, String> {
400
+ fn try_parse_raw_body(raw_body: Option<&Vec<u8>>) -> Result<Option<JsonValue>, String> {
413
401
  let Some(bytes) = raw_body else {
414
402
  return Ok(None);
415
403
  };
416
- let text = String::from_utf8(bytes.clone()).map_err(|e| format!("Invalid UTF-8 in response body: {e}"))?;
417
- if text.is_empty() {
404
+ if bytes.is_empty() {
418
405
  return Ok(None);
419
406
  }
420
- serde_json::from_str(&text)
407
+ // PERFORMANCE: Use from_slice directly to avoid String allocation.
408
+ // serde_json handles UTF-8 validation internally.
409
+ serde_json::from_slice(bytes)
421
410
  .map(Some)
422
411
  .map_err(|e| format!("Failed to parse response body as JSON: {e}"))
423
412
  }
@@ -432,77 +421,166 @@ fn lookup_upload_file_class() -> Result<Option<Opaque<Value>>, Error> {
432
421
  Ok(upload_file.map(Opaque::from))
433
422
  }
434
423
 
435
- /// Build a Ruby Hash request object from request data.
436
- fn build_ruby_request(
437
- ruby: &Ruby,
438
- handler: &RubyHandlerInner,
439
- request_data: &RequestData,
440
- validated_params: Option<&JsonValue>,
441
- ) -> Result<Value, Error> {
442
- let hash = ruby.hash_new_capa(9);
424
+ fn call_handler_proc(ruby: &Ruby, handler_proc: Value, request_value: Value) -> Result<Value, Error> {
425
+ let arity: i64 = handler_proc.funcall("arity", ())?;
426
+ if arity == 0 {
427
+ return handler_proc.funcall("call", ());
428
+ }
429
+
430
+ if arity == 1 {
431
+ return handler_proc.funcall("call", (request_value,));
432
+ }
443
433
 
444
- hash.aset(*KEY_METHOD, handler.method_value.get_inner_with(ruby))?;
445
- hash.aset(*KEY_PATH, handler.path_value.get_inner_with(ruby))?;
434
+ let (params_value, query_value, body_value) = if let Ok(request) = <&NativeRequest>::try_convert(request_value) {
435
+ (
436
+ NativeRequest::path_params(ruby, request).unwrap_or_else(|_| ruby.qnil().as_value()),
437
+ NativeRequest::query(ruby, request).unwrap_or_else(|_| ruby.qnil().as_value()),
438
+ NativeRequest::body(ruby, request).unwrap_or_else(|_| ruby.qnil().as_value()),
439
+ )
440
+ } else if let Some(hash) = RHash::from_value(request_value) {
441
+ (
442
+ hash.get(*KEY_PATH_PARAMS).unwrap_or_else(|| ruby.qnil().as_value()),
443
+ hash.get(*KEY_QUERY).unwrap_or_else(|| ruby.qnil().as_value()),
444
+ hash.get(*KEY_BODY).unwrap_or_else(|| ruby.qnil().as_value()),
445
+ )
446
+ } else {
447
+ (ruby.qnil().as_value(), ruby.qnil().as_value(), ruby.qnil().as_value())
448
+ };
449
+
450
+ if arity == 2 {
451
+ return handler_proc.funcall("call", (params_value, query_value));
452
+ }
446
453
 
447
- let path_params = map_to_ruby_hash(ruby, request_data.path_params.as_ref())?;
448
- hash.aset(*KEY_PATH_PARAMS, path_params)?;
454
+ handler_proc.funcall("call", (params_value, query_value, body_value))
455
+ }
449
456
 
450
- let query_value = json_to_ruby(ruby, &request_data.query_params)?;
451
- hash.aset(*KEY_QUERY, query_value)?;
457
+ fn problem_from_ruby_error(ruby: &Ruby, handler: &RubyHandlerInner, err: Error) -> (StatusCode, String) {
458
+ let mut status = StatusCode::INTERNAL_SERVER_ERROR;
459
+ let mut extensions: HashMap<String, JsonValue> = HashMap::new();
460
+ let mut detail = ruby_error_message(ruby, &err);
452
461
 
453
- let raw_query = multimap_to_ruby_hash(ruby, request_data.raw_query_params.as_ref())?;
454
- hash.aset(*KEY_RAW_QUERY, raw_query)?;
462
+ if err.is_kind_of(ruby.exception_arg_error()) {
463
+ status = StatusCode::BAD_REQUEST;
464
+ }
455
465
 
456
- let headers = map_to_ruby_hash(ruby, request_data.headers.as_ref())?;
457
- hash.aset(*KEY_HEADERS, headers)?;
466
+ if let Some(exception) = err.value() {
467
+ if matches!(exception.respond_to("status", false), Ok(true)) {
468
+ if let Ok(code) = exception.funcall::<_, _, i64>("status", ()) {
469
+ status = StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
470
+ }
471
+ } else if matches!(exception.respond_to("status_code", false), Ok(true))
472
+ && let Ok(code) = exception.funcall::<_, _, i64>("status_code", ())
473
+ {
474
+ status = StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
475
+ }
458
476
 
459
- let cookies = map_to_ruby_hash(ruby, request_data.cookies.as_ref())?;
460
- hash.aset(*KEY_COOKIES, cookies)?;
477
+ let json_module = handler.json_module.get_inner_with(ruby);
478
+ if matches!(exception.respond_to("code", false), Ok(true))
479
+ && let Ok(value) = exception.funcall::<_, _, Value>("code", ())
480
+ && let Ok(json_value) = ruby_value_to_json(ruby, json_module, value)
481
+ {
482
+ extensions.insert("code".to_string(), json_value);
483
+ }
461
484
 
462
- let upload_class_value = handler.upload_file_class.as_ref().map(|cls| cls.get_inner_with(ruby));
463
- let body_value = json_to_ruby_with_uploads(ruby, &request_data.body, upload_class_value.as_ref())?;
464
- hash.aset(*KEY_BODY, body_value)?;
465
- if let Some(raw) = &request_data.raw_body {
466
- let raw_str = ruby.str_from_slice(raw);
467
- hash.aset(*KEY_RAW_BODY, raw_str)?;
468
- } else {
469
- hash.aset(*KEY_RAW_BODY, ruby.qnil())?;
485
+ if matches!(exception.respond_to("details", false), Ok(true))
486
+ && let Ok(value) = exception.funcall::<_, _, Value>("details", ())
487
+ && let Ok(json_value) = ruby_value_to_json(ruby, json_module, value)
488
+ {
489
+ extensions.insert("details".to_string(), json_value);
490
+ }
470
491
  }
471
492
 
472
- let params_value = if let Some(validated) = validated_params {
473
- json_to_ruby(ruby, validated)?
474
- } else {
475
- build_default_params_from_converted(ruby, path_params, query_value, headers, cookies)?
476
- };
477
- hash.aset(*KEY_PARAMS, params_value)?;
493
+ detail = sanitize_error_detail(&detail);
494
+
495
+ let mut problem = problem_for_status(status, detail);
496
+ for (key, value) in extensions {
497
+ problem = problem.with_extension(key, value);
498
+ }
478
499
 
479
- Ok(hash.as_value())
500
+ ErrorResponseBuilder::problem_details_response(&problem)
480
501
  }
481
502
 
482
- /// Build default params from already converted Ruby values, avoiding double conversion.
483
- fn build_default_params_from_converted(
484
- ruby: &Ruby,
485
- path_params: Value,
486
- query: Value,
487
- headers: Value,
488
- cookies: Value,
489
- ) -> Result<Value, Error> {
490
- let params = ruby.hash_new();
491
-
492
- if let Some(hash) = RHash::from_value(path_params) {
493
- let _: Value = params.funcall("merge!", (hash,))?;
494
- }
495
- if let Some(hash) = RHash::from_value(query) {
496
- let _: Value = params.funcall("merge!", (hash,))?;
503
+ fn ruby_error_message(_ruby: &Ruby, err: &Error) -> String {
504
+ if let Some(exception) = err.value()
505
+ && matches!(exception.respond_to("message", false), Ok(true))
506
+ && let Ok(message) = exception.funcall::<_, _, String>("message", ())
507
+ {
508
+ return message;
497
509
  }
498
- if let Some(hash) = RHash::from_value(headers) {
499
- let _: Value = params.funcall("merge!", (hash,))?;
510
+ err.to_string()
511
+ }
512
+
513
+ fn problem_for_status(status: StatusCode, detail: String) -> ProblemDetails {
514
+ match status {
515
+ StatusCode::BAD_REQUEST => ProblemDetails::bad_request(detail),
516
+ StatusCode::UNAUTHORIZED => {
517
+ ProblemDetails::new("https://spikard.dev/errors/unauthorized", "Unauthorized", status).with_detail(detail)
518
+ }
519
+ StatusCode::FORBIDDEN => {
520
+ ProblemDetails::new("https://spikard.dev/errors/forbidden", "Forbidden", status).with_detail(detail)
521
+ }
522
+ StatusCode::NOT_FOUND => ProblemDetails::not_found(detail),
523
+ StatusCode::UNPROCESSABLE_ENTITY => ProblemDetails::new(
524
+ ProblemDetails::TYPE_VALIDATION_ERROR,
525
+ "Request Validation Failed",
526
+ status,
527
+ )
528
+ .with_detail(detail),
529
+ _ => ProblemDetails::internal_server_error(detail),
500
530
  }
501
- if let Some(hash) = RHash::from_value(cookies) {
502
- let _: Value = params.funcall("merge!", (hash,))?;
531
+ }
532
+
533
+ fn sanitize_error_detail(detail: &str) -> String {
534
+ let mut tokens = Vec::new();
535
+ let mut redact_next = false;
536
+
537
+ for token in detail.split_whitespace() {
538
+ let lower = token.to_lowercase();
539
+ if token.starts_with('/') || token.contains(".rb:") {
540
+ tokens.push("[redacted]".to_string());
541
+ redact_next = false;
542
+ continue;
543
+ }
544
+
545
+ if lower.starts_with("password=") {
546
+ tokens.push("password=[redacted]".to_string());
547
+ redact_next = false;
548
+ continue;
549
+ }
550
+
551
+ if lower.starts_with("host=") {
552
+ tokens.push("host=[redacted]".to_string());
553
+ redact_next = false;
554
+ continue;
555
+ }
556
+
557
+ if lower.starts_with("token=") || lower.starts_with("secret=") {
558
+ tokens.push("[redacted]".to_string());
559
+ redact_next = false;
560
+ continue;
561
+ }
562
+
563
+ if redact_next {
564
+ tokens.push("[redacted]".to_string());
565
+ redact_next = false;
566
+ continue;
567
+ }
568
+
569
+ if token.eq_ignore_ascii_case("in") {
570
+ tokens.push(token.to_string());
571
+ redact_next = true;
572
+ continue;
573
+ }
574
+
575
+ tokens.push(token.to_string());
503
576
  }
504
577
 
505
- Ok(params.as_value())
578
+ let mut sanitized = tokens.join(" ");
579
+ sanitized = sanitized.replace("SELECT *", "[redacted]");
580
+ sanitized = sanitized.replace("select *", "[redacted]");
581
+ sanitized = sanitized.replace("FROM users", "[redacted]");
582
+ sanitized = sanitized.replace("from users", "[redacted]");
583
+ sanitized
506
584
  }
507
585
 
508
586
  /// Interpret a Ruby handler response into our response types.