spikard 0.2.5 → 0.3.0

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +34 -1
  3. data/ext/spikard_rb/Cargo.toml +3 -3
  4. data/lib/spikard/app.rb +61 -49
  5. data/lib/spikard/converters.rb +3 -75
  6. data/lib/spikard/handler_wrapper.rb +6 -9
  7. data/lib/spikard/provide.rb +14 -28
  8. data/lib/spikard/response.rb +75 -11
  9. data/lib/spikard/streaming_response.rb +24 -1
  10. data/lib/spikard/testing.rb +1 -1
  11. data/lib/spikard/version.rb +1 -1
  12. data/sig/spikard.rbs +14 -3
  13. data/vendor/bundle/ruby/3.3.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +2 -0
  14. metadata +3 -80
  15. data/vendor/crates/spikard-core/Cargo.toml +0 -40
  16. data/vendor/crates/spikard-core/src/bindings/mod.rs +0 -3
  17. data/vendor/crates/spikard-core/src/bindings/response.rs +0 -133
  18. data/vendor/crates/spikard-core/src/debug.rs +0 -63
  19. data/vendor/crates/spikard-core/src/di/container.rs +0 -726
  20. data/vendor/crates/spikard-core/src/di/dependency.rs +0 -273
  21. data/vendor/crates/spikard-core/src/di/error.rs +0 -118
  22. data/vendor/crates/spikard-core/src/di/factory.rs +0 -538
  23. data/vendor/crates/spikard-core/src/di/graph.rs +0 -545
  24. data/vendor/crates/spikard-core/src/di/mod.rs +0 -192
  25. data/vendor/crates/spikard-core/src/di/resolved.rs +0 -411
  26. data/vendor/crates/spikard-core/src/di/value.rs +0 -283
  27. data/vendor/crates/spikard-core/src/http.rs +0 -153
  28. data/vendor/crates/spikard-core/src/lib.rs +0 -28
  29. data/vendor/crates/spikard-core/src/lifecycle.rs +0 -422
  30. data/vendor/crates/spikard-core/src/parameters.rs +0 -719
  31. data/vendor/crates/spikard-core/src/problem.rs +0 -310
  32. data/vendor/crates/spikard-core/src/request_data.rs +0 -189
  33. data/vendor/crates/spikard-core/src/router.rs +0 -249
  34. data/vendor/crates/spikard-core/src/schema_registry.rs +0 -183
  35. data/vendor/crates/spikard-core/src/type_hints.rs +0 -304
  36. data/vendor/crates/spikard-core/src/validation.rs +0 -699
  37. data/vendor/crates/spikard-http/Cargo.toml +0 -58
  38. data/vendor/crates/spikard-http/src/auth.rs +0 -247
  39. data/vendor/crates/spikard-http/src/background.rs +0 -249
  40. data/vendor/crates/spikard-http/src/bindings/mod.rs +0 -3
  41. data/vendor/crates/spikard-http/src/bindings/response.rs +0 -1
  42. data/vendor/crates/spikard-http/src/body_metadata.rs +0 -8
  43. data/vendor/crates/spikard-http/src/cors.rs +0 -490
  44. data/vendor/crates/spikard-http/src/debug.rs +0 -63
  45. data/vendor/crates/spikard-http/src/di_handler.rs +0 -423
  46. data/vendor/crates/spikard-http/src/handler_response.rs +0 -190
  47. data/vendor/crates/spikard-http/src/handler_trait.rs +0 -228
  48. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +0 -284
  49. data/vendor/crates/spikard-http/src/lib.rs +0 -529
  50. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +0 -149
  51. data/vendor/crates/spikard-http/src/lifecycle.rs +0 -428
  52. data/vendor/crates/spikard-http/src/middleware/mod.rs +0 -285
  53. data/vendor/crates/spikard-http/src/middleware/multipart.rs +0 -86
  54. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +0 -147
  55. data/vendor/crates/spikard-http/src/middleware/validation.rs +0 -287
  56. data/vendor/crates/spikard-http/src/openapi/mod.rs +0 -309
  57. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +0 -190
  58. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +0 -308
  59. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +0 -195
  60. data/vendor/crates/spikard-http/src/parameters.rs +0 -1
  61. data/vendor/crates/spikard-http/src/problem.rs +0 -1
  62. data/vendor/crates/spikard-http/src/query_parser.rs +0 -369
  63. data/vendor/crates/spikard-http/src/response.rs +0 -399
  64. data/vendor/crates/spikard-http/src/router.rs +0 -1
  65. data/vendor/crates/spikard-http/src/schema_registry.rs +0 -1
  66. data/vendor/crates/spikard-http/src/server/handler.rs +0 -80
  67. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +0 -98
  68. data/vendor/crates/spikard-http/src/server/mod.rs +0 -805
  69. data/vendor/crates/spikard-http/src/server/request_extraction.rs +0 -119
  70. data/vendor/crates/spikard-http/src/sse.rs +0 -447
  71. data/vendor/crates/spikard-http/src/testing/form.rs +0 -14
  72. data/vendor/crates/spikard-http/src/testing/multipart.rs +0 -60
  73. data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -285
  74. data/vendor/crates/spikard-http/src/testing.rs +0 -377
  75. data/vendor/crates/spikard-http/src/type_hints.rs +0 -1
  76. data/vendor/crates/spikard-http/src/validation.rs +0 -1
  77. data/vendor/crates/spikard-http/src/websocket.rs +0 -324
  78. data/vendor/crates/spikard-rb/Cargo.toml +0 -42
  79. data/vendor/crates/spikard-rb/build.rs +0 -8
  80. data/vendor/crates/spikard-rb/src/background.rs +0 -63
  81. data/vendor/crates/spikard-rb/src/config.rs +0 -294
  82. data/vendor/crates/spikard-rb/src/conversion.rs +0 -392
  83. data/vendor/crates/spikard-rb/src/di.rs +0 -409
  84. data/vendor/crates/spikard-rb/src/handler.rs +0 -534
  85. data/vendor/crates/spikard-rb/src/lib.rs +0 -2020
  86. data/vendor/crates/spikard-rb/src/lifecycle.rs +0 -267
  87. data/vendor/crates/spikard-rb/src/server.rs +0 -283
  88. data/vendor/crates/spikard-rb/src/sse.rs +0 -231
  89. data/vendor/crates/spikard-rb/src/test_client.rs +0 -404
  90. data/vendor/crates/spikard-rb/src/test_sse.rs +0 -143
  91. data/vendor/crates/spikard-rb/src/test_websocket.rs +0 -221
  92. data/vendor/crates/spikard-rb/src/websocket.rs +0 -233
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21d1d862d3d5601a97e14de7cd38ea5b8ae58e424e0ab0996c318e0d09347f40
4
- data.tar.gz: 44393a37df38446867e34d079e732e4269b88e4b8b2f4fd71deb681a0994c97b
3
+ metadata.gz: c70da2d00ebf8c9d79e3ef9f0a76743f39c9a0ac5d13cf723f7f87920bf5d199
4
+ data.tar.gz: 1fdc0938e1974995cfb46d960c1d2e6d2266c64c81077a6c2d6a046e8d045f21
5
5
  SHA512:
6
- metadata.gz: e053bd46853a7dfd4950b4273491435700d9308f04b412707e6f404d6c4ef3c9cb79a64a1686ffa313ce3cd65a7bcd6aebba78afb2c2cc193f3fb13bcfdd00f9
7
- data.tar.gz: 2f80c651e36c0d6412e017525b074fc6f7d139fc84fb68691ab2e785803cb9d6ccbb3a5ca50cc85798b13bbd3da091520a3c28113ff20af1d0cc6c68ef34d965
6
+ metadata.gz: 05d3e4e7859dbab147d66c246ffae6e412df42ebc532b1a55c60ed25fa952d25a092e53458759e2b8f046539c08ffd17d7159d3b0ef72e1b79ae48d2cf3a0f45
7
+ data.tar.gz: bc6c6d79bdf8f709443a08642b27939534f15cb4faf842f7a3380acf1f85ae4b449dc653b233234cdad0ad83b7fe6a5e3b120d39d0d7a341035a18618707fb73
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Gem Downloads](https://img.shields.io/gem/dt/spikard.svg)](https://rubygems.org/gems/spikard)
6
6
  [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.2-red.svg)](https://www.ruby-lang.org/)
7
7
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
- [![CI Status](https://img.shields.io/github/actions/workflow/status/Goldziher/spikard/ci.yml?branch=main)](https://github.com/Goldziher/spikard/actions)
8
+ [![codecov](https://codecov.io/gh/Goldziher/spikard/graph/badge.svg?token=H4ZXDZ4A69)](https://codecov.io/gh/Goldziher/spikard)
9
9
  [![PyPI](https://img.shields.io/pypi/v/spikard.svg)](https://pypi.org/project/spikard/)
10
10
  [![npm](https://img.shields.io/npm/v/spikard.svg)](https://www.npmjs.com/package/spikard)
11
11
  [![Crates.io](https://img.shields.io/crates/v/spikard.svg)](https://crates.io/crates/spikard)
@@ -47,6 +47,39 @@ bundle exec rake ext:build
47
47
  - Bundler
48
48
  - Rust toolchain (for building from source)
49
49
 
50
+ ## Windows Development
51
+
52
+ On Windows, Spikard uses the GNU toolchain (not MSVC) to match Ruby's official RubyInstaller distribution.
53
+
54
+ ### Prerequisites
55
+
56
+ 1. **Install RubyInstaller with DevKit:**
57
+ - Download from [RubyInstaller.org](https://rubyinstaller.org/downloads/)
58
+ - Choose Ruby+Devkit 3.2.x (x64)
59
+ - During installation, select "MSYS2 development toolchain"
60
+
61
+ 2. **Install Rust with GNU target:**
62
+ ```powershell
63
+ rustup toolchain install stable-x86_64-pc-windows-gnu
64
+ rustup default stable-x86_64-pc-windows-gnu
65
+ ```
66
+
67
+ 3. **Verify setup:**
68
+ ```powershell
69
+ ruby --version # Should show 3.2.x
70
+ rustup show # Should show *-pc-windows-gnu
71
+ ```
72
+
73
+ ### Building on Windows
74
+
75
+ ```bash
76
+ cd packages/ruby
77
+ bundle install
78
+ bundle exec rake compile
79
+ ```
80
+
81
+ The build uses the GNU toolchain automatically via RubyInstaller's MSYS2 DevKit. No MSVC configuration needed.
82
+
50
83
  ## Quick Start
51
84
 
52
85
  ```ruby
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-rb-ext"
3
- version = "0.2.5"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  license = "MIT"
6
6
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
@@ -13,5 +13,5 @@ crate-type = ["cdylib"]
13
13
 
14
14
  [dependencies]
15
15
  magnus = { git = "https://github.com/matsadler/magnus", rev = "f6db11769efb517427bf7f121f9c32e18b059b38", features = ["rb-sys"] }
16
- # Vendored workspace crates - created by rake vendor:sync before gem build
17
- spikard_rb_core = { package = "spikard-rb", path = "../../vendor/crates/spikard-rb" }
16
+ # Use workspace crate directly (no vendoring for local builds/tests)
17
+ spikard_rb_core = { package = "spikard-rb", path = "../../../../crates/spikard-rb" }
data/lib/spikard/app.rb CHANGED
@@ -20,7 +20,7 @@ module Spikard
20
20
  # request
21
21
  # end
22
22
  def on_request(&hook)
23
- @lifecycle_hooks[:on_request] << hook
23
+ native_hooks.add_on_request(hook)
24
24
  hook
25
25
  end
26
26
 
@@ -42,7 +42,7 @@ module Spikard
42
42
  # end
43
43
  # end
44
44
  def pre_validation(&hook)
45
- @lifecycle_hooks[:pre_validation] << hook
45
+ native_hooks.add_pre_validation(hook)
46
46
  hook
47
47
  end
48
48
 
@@ -64,7 +64,7 @@ module Spikard
64
64
  # end
65
65
  # end
66
66
  def pre_handler(&hook)
67
- @lifecycle_hooks[:pre_handler] << hook
67
+ native_hooks.add_pre_handler(hook)
68
68
  hook
69
69
  end
70
70
 
@@ -81,7 +81,7 @@ module Spikard
81
81
  # response
82
82
  # end
83
83
  def on_response(&hook)
84
- @lifecycle_hooks[:on_response] << hook
84
+ native_hooks.add_on_response(hook)
85
85
  hook
86
86
  end
87
87
 
@@ -98,21 +98,16 @@ module Spikard
98
98
  # response
99
99
  # end
100
100
  def on_error(&hook)
101
- @lifecycle_hooks[:on_error] << hook
101
+ native_hooks.add_on_error(hook)
102
102
  hook
103
103
  end
104
104
 
105
- # Get all registered lifecycle hooks
106
- #
107
- # @return [Hash] Dictionary of hook arrays by type
108
- def lifecycle_hooks
109
- {
110
- on_request: @lifecycle_hooks[:on_request].dup,
111
- pre_validation: @lifecycle_hooks[:pre_validation].dup,
112
- pre_handler: @lifecycle_hooks[:pre_handler].dup,
113
- on_response: @lifecycle_hooks[:on_response].dup,
114
- on_error: @lifecycle_hooks[:on_error].dup
115
- }
105
+ private
106
+
107
+ def native_hooks
108
+ raise 'Spikard native lifecycle registry unavailable' unless defined?(@native_hooks) && @native_hooks
109
+
110
+ @native_hooks
116
111
  end
117
112
  end
118
113
 
@@ -123,7 +118,8 @@ module Spikard
123
118
  include ProvideSupport
124
119
 
125
120
  HTTP_METHODS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
126
- SUPPORTED_OPTIONS = %i[request_schema response_schema parameter_schema file_params is_async cors].freeze
121
+ SUPPORTED_OPTIONS = %i[request_schema response_schema parameter_schema file_params is_async cors
122
+ body_param_name].freeze
127
123
 
128
124
  attr_reader :routes
129
125
 
@@ -131,28 +127,43 @@ module Spikard
131
127
  @routes = []
132
128
  @websocket_handlers = {}
133
129
  @sse_producers = {}
134
- @dependencies = {}
135
- @lifecycle_hooks = {
136
- on_request: [],
137
- pre_validation: [],
138
- pre_handler: [],
139
- on_response: [],
140
- on_error: []
141
- }
130
+ @native_hooks = Spikard::Native::LifecycleRegistry.new
131
+ @native_dependencies = Spikard::Native::DependencyRegistry.new
142
132
  end
143
133
 
134
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
144
135
  def register_route(method, path, handler_name: nil, **options, &block)
136
+ method = method.to_s
137
+ path = path.to_s
138
+ handler_name = handler_name&.to_s
145
139
  validate_route_arguments!(block, options)
146
- handler_name ||= default_handler_name(method, path)
147
-
148
- # Extract handler dependencies from block parameters
149
- handler_dependencies = extract_handler_dependencies(block)
150
-
151
- metadata = build_metadata(method, path, handler_name, options, handler_dependencies)
140
+ metadata = if defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_route_metadata)
141
+ Spikard::Native.build_route_metadata(
142
+ method,
143
+ path,
144
+ handler_name,
145
+ options[:request_schema],
146
+ options[:response_schema],
147
+ options[:parameter_schema],
148
+ options[:file_params],
149
+ options.fetch(:is_async, false),
150
+ options[:cors],
151
+ options[:body_param_name]&.to_s,
152
+ block
153
+ )
154
+ else
155
+ handler_name ||= default_handler_name(method, path)
156
+
157
+ # Extract handler dependencies from block parameters
158
+ handler_dependencies = extract_handler_dependencies(block)
159
+
160
+ build_metadata(method, path, handler_name, options, handler_dependencies)
161
+ end
152
162
 
153
163
  @routes << RouteEntry.new(metadata, block)
154
164
  block
155
165
  end
166
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
156
167
 
157
168
  HTTP_METHODS.each do |verb|
158
169
  define_method(verb.downcase) do |path, handler_name: nil, **options, &block|
@@ -161,17 +172,7 @@ module Spikard
161
172
  end
162
173
 
163
174
  def route_metadata
164
- # Extract handler dependencies when metadata is requested
165
- # This allows dependencies to be registered after routes
166
- @routes.map do |entry|
167
- metadata = entry.metadata.dup
168
-
169
- # Re-extract dependencies in case they were registered after the route
170
- handler_dependencies = extract_handler_dependencies(entry.handler)
171
- metadata[:handler_dependencies] = handler_dependencies unless handler_dependencies.empty?
172
-
173
- metadata
174
- end
175
+ @routes.map(&:metadata)
175
176
  end
176
177
 
177
178
  def handler_map
@@ -184,8 +185,20 @@ module Spikard
184
185
  map
185
186
  end
186
187
 
188
+ def normalized_routes_json
189
+ json = JSON.generate(route_metadata)
190
+ if defined?(Spikard::Native) && Spikard::Native.respond_to?(:normalize_route_metadata)
191
+ Spikard::Native.normalize_route_metadata(json)
192
+ else
193
+ json
194
+ end
195
+ end
196
+
187
197
  def default_handler_name(method, path)
188
- normalized_path = path.gsub(/[^a-zA-Z0-9]+/, '_').gsub(/__+/, '_').sub(/^_+|_+$/, '')
198
+ normalized_path = path.gsub(/[^a-zA-Z0-9]+/, '_').gsub(/__+/, '_')
199
+ # ReDoS mitigation: use bounded quantifier {1,100} instead of + to prevent
200
+ # polynomial time complexity with excessive trailing underscores
201
+ normalized_path = normalized_path.sub(/^_{1,100}/, '').sub(/_{1,100}$/, '')
189
202
  normalized_path = 'root' if normalized_path.empty?
190
203
  "#{method.to_s.downcase}_#{normalized_path}"
191
204
  end
@@ -258,7 +271,7 @@ module Spikard
258
271
  #
259
272
  # @example Backward compatible (deprecated)
260
273
  # app.run(host: '0.0.0.0', port: 8000)
261
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
274
+ # rubocop:disable Metrics/MethodLength
262
275
  def run(config: nil, host: nil, port: nil)
263
276
  require 'json'
264
277
 
@@ -274,21 +287,20 @@ module Spikard
274
287
  config = ServerConfig.new(**config)
275
288
  end
276
289
 
277
- # Convert route metadata to JSON
278
- routes_json = JSON.generate(route_metadata)
290
+ routes_json = normalized_routes_json
279
291
 
280
292
  # Get handler map
281
293
  handlers = handler_map
282
294
 
283
295
  # Get lifecycle hooks
284
- hooks = lifecycle_hooks
296
+ hooks = @native_hooks
285
297
 
286
298
  # Get WebSocket handlers and SSE producers
287
299
  ws_handlers = websocket_handlers
288
300
  sse_prods = sse_producers
289
301
 
290
302
  # Get dependencies for DI
291
- deps = dependencies
303
+ deps = @native_dependencies
292
304
 
293
305
  # Call the Rust extension's run_server function
294
306
  Spikard::Native.run_server(routes_json, handlers, config, hooks, ws_handlers, sse_prods, deps)
@@ -299,7 +311,7 @@ module Spikard
299
311
  raise 'Failed to load Spikard extension. ' \
300
312
  "Build it with: task build:ruby\n#{e.message}"
301
313
  end
302
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
314
+ # rubocop:enable Metrics/MethodLength
303
315
 
304
316
  private
305
317
 
@@ -1,85 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'upload_file'
4
-
5
3
  module Spikard
6
- # Type conversion utilities for handler parameters
7
- #
8
- # This module handles converting validated JSON data from Rust into Ruby types,
9
- # particularly for UploadFile instances.
4
+ # Conversion helpers between native Rust values and Ruby types.
10
5
  module Converters
11
6
  module_function
12
7
 
13
- # Check if a value looks like file metadata from Rust
14
- #
15
- # @param value [Object] Value to check
16
- # @return [Boolean]
17
- def file_metadata?(value)
18
- value.is_a?(Hash) && value.key?('filename') && value.key?('content')
19
- end
20
-
21
- # Convert file metadata hash to UploadFile instance
22
- #
23
- # @param file_data [Hash] File metadata from Rust (filename, content, size, content_type)
24
- # @return [UploadFile] UploadFile instance
25
- def convert_file_metadata_to_upload_file(file_data)
26
- UploadFile.new(
27
- file_data['filename'],
28
- file_data['content'],
29
- content_type: file_data['content_type'],
30
- size: file_data['size'],
31
- headers: file_data['headers'],
32
- content_encoding: file_data['content_encoding']
33
- )
34
- end
35
-
36
- # Process handler parameters, converting file metadata to UploadFile instances
37
- #
38
- # This method recursively processes the body parameter, looking for file metadata
39
- # structures and converting them to UploadFile instances.
40
- #
41
- # @param value [Object] The value to process (can be Hash, Array, or primitive)
42
- # @return [Object] Processed value with UploadFile instances
43
- def process_upload_file_fields(value)
44
- # Handle nil
45
- return value if value.nil?
46
-
47
- # Handle primitives (String, Numeric, Boolean)
48
- return value unless value.is_a?(Hash) || value.is_a?(Array)
49
-
50
- # Handle arrays - recursively process each element
51
- if value.is_a?(Array)
52
- return value.map do |item|
53
- # Check if this array item is file metadata
54
- if file_metadata?(item)
55
- convert_file_metadata_to_upload_file(item)
56
- else
57
- # Recursively process nested arrays/hashes
58
- process_upload_file_fields(item)
59
- end
60
- end
61
- end
62
-
63
- # Handle hashes - check if it's file metadata first
64
- return convert_file_metadata_to_upload_file(value) if file_metadata?(value)
65
-
66
- # Otherwise, recursively process hash values
67
- value.transform_values { |v| process_upload_file_fields(v) }
68
- end
69
-
70
- # Process handler body parameter, handling UploadFile conversion
71
- #
72
- # This is the main entry point for converting Rust-provided request data
73
- # into Ruby types. It handles:
74
- # - Single UploadFile
75
- # - Arrays of UploadFile
76
- # - Hashes with UploadFile fields
77
- # - Nested structures
78
- #
79
- # @param body [Object] The body parameter from Rust (already JSON-parsed)
80
- # @return [Object] Processed body with UploadFile instances
8
+ # No-op conversion now that Rust materialises UploadFile.
81
9
  def convert_handler_body(body)
82
- process_upload_file_fields(body)
10
+ body
83
11
  end
84
12
  end
85
13
  end
@@ -3,10 +3,10 @@
3
3
  require_relative 'converters'
4
4
 
5
5
  module Spikard
6
- # Handler wrapper utilities for automatic file metadata conversion
6
+ # Handler wrapper utilities.
7
7
  #
8
- # Provides ergonomic handler patterns that automatically convert
9
- # file metadata to UploadFile instances, eliminating boilerplate.
8
+ # UploadFile conversion now happens in the Rust binding, so these wrappers
9
+ # simply forward the already-converted body/params.
10
10
  #
11
11
  # @example Basic usage with body only
12
12
  # app.post('/upload', &wrap_body_handler do |body|
@@ -46,8 +46,7 @@ module Spikard
46
46
  # Return a proc that matches the signature expected by Spikard::App
47
47
  # The actual handler receives path params, query params, and body from Rust
48
48
  lambda do |_params, _query, body|
49
- converted_body = Converters.convert_handler_body(body)
50
- handler.call(converted_body)
49
+ handler.call(body)
51
50
  end
52
51
  end
53
52
 
@@ -74,8 +73,7 @@ module Spikard
74
73
  raise ArgumentError, 'block required for wrap_handler' unless handler
75
74
 
76
75
  lambda do |params, query, body|
77
- converted_body = Converters.convert_handler_body(body)
78
- handler.call(params, query, converted_body)
76
+ handler.call(params, query, body)
79
77
  end
80
78
  end
81
79
 
@@ -103,11 +101,10 @@ module Spikard
103
101
  raise ArgumentError, 'block required for wrap_handler_with_context' unless handler
104
102
 
105
103
  lambda do |params, query, body|
106
- converted_body = Converters.convert_handler_body(body)
107
104
  context = {
108
105
  params: params,
109
106
  query: query,
110
- body: converted_body
107
+ body: body
111
108
  }
112
109
  handler.call(context)
113
110
  end
@@ -107,53 +107,39 @@ module Spikard
107
107
  #
108
108
  # @example Using Provide wrapper
109
109
  # app.provide("db", Spikard::Provide.new(method("create_db"), cacheable: true))
110
- # rubocop:disable Metrics/MethodLength
111
110
  def provide(key, value = nil, depends_on: [], singleton: false, cacheable: true, &block)
112
111
  key_str = key.to_s
113
- @dependencies ||= {}
112
+ registry = ensure_native_dependencies!
114
113
 
115
114
  # Handle Provide wrapper instances
116
115
  if value.is_a?(Provide)
117
- provider = value
118
- @dependencies[key_str] = {
119
- type: :factory,
120
- factory: provider.factory,
121
- depends_on: provider.depends_on,
122
- singleton: provider.singleton,
123
- cacheable: provider.cacheable
124
- }
116
+ registry.register_factory(key_str, value.factory, value.depends_on, value.singleton, value.cacheable)
125
117
  elsif block
126
- # Factory dependency (block form)
127
- @dependencies[key_str] = {
128
- type: :factory,
129
- factory: block,
130
- depends_on: Array(depends_on).map(&:to_s),
131
- singleton: singleton,
132
- cacheable: cacheable
133
- }
118
+ registry.register_factory(key_str, block, Array(depends_on).map(&:to_s), singleton, cacheable)
134
119
  else
135
- # Value dependency
136
120
  raise ArgumentError, 'Either provide a value or a block, not both' if value.nil?
137
121
 
138
- @dependencies[key_str] = {
139
- type: :value,
140
- value: value,
141
- singleton: true, # Values are always singleton
142
- cacheable: true
143
- }
122
+ registry.register_value(key_str, value)
144
123
  end
145
124
 
146
125
  self
147
126
  end
148
- # rubocop:enable Metrics/MethodLength
149
127
 
150
128
  # Get all registered dependencies
151
129
  #
152
130
  # @return [Hash] Dictionary mapping dependency keys to their definitions
153
131
  # @api private
154
132
  def dependencies
155
- @dependencies ||= {}
156
- @dependencies.dup
133
+ ensure_native_dependencies!
134
+ end
135
+
136
+ private
137
+
138
+ def ensure_native_dependencies!
139
+ registry = (@native_dependencies if instance_variable_defined?(:@native_dependencies) && @native_dependencies)
140
+ raise 'Spikard native dependency registry unavailable' unless registry
141
+
142
+ registry
157
143
  end
158
144
  end
159
145
 
@@ -1,18 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # ⚠️ GENERATED BY crates/spikard-rb/build.rs — DO NOT EDIT BY HAND
3
4
  module Spikard
4
- # Response object returned from route handlers.
5
- # Mirrors the Python/Node response helpers so the native layer
6
- # can extract status, headers, and JSON-serialisable content.
7
- class Response
8
- attr_accessor :content
9
- attr_reader :status_code, :headers
5
+ class Response # :nodoc: Native-backed HTTP response facade generated from Rust metadata.
6
+ attr_reader :content, :status_code, :headers, :native_response
10
7
 
11
8
  def initialize(content: nil, body: nil, status_code: 200, headers: nil, content_type: nil)
12
9
  @content = content.nil? ? body : content
13
- self.status_code = status_code
14
- self.headers = headers
10
+ @status_code = Integer(status_code || 200)
11
+ @headers = normalize_headers(headers)
15
12
  set_header('content-type', content_type) if content_type
13
+ rebuild_native!
16
14
  end
17
15
 
18
16
  def status
@@ -21,16 +19,24 @@ module Spikard
21
19
 
22
20
  def status_code=(value)
23
21
  @status_code = Integer(value)
22
+ rebuild_native!
24
23
  rescue ArgumentError, TypeError
25
24
  raise ArgumentError, 'status_code must be an integer'
26
25
  end
27
26
 
28
27
  def headers=(value)
29
28
  @headers = normalize_headers(value)
29
+ rebuild_native!
30
+ end
31
+
32
+ def content=(value)
33
+ @content = value
34
+ rebuild_native!
30
35
  end
31
36
 
32
37
  def set_header(name, value)
33
38
  @headers[name.to_s] = value.to_s
39
+ rebuild_native!
34
40
  end
35
41
 
36
42
  def set_cookie(name, value, **options)
@@ -40,8 +46,27 @@ module Spikard
40
46
  set_header('set-cookie', header_value)
41
47
  end
42
48
 
49
+ def to_native_response
50
+ @native_response
51
+ end
52
+
43
53
  private
44
54
 
55
+ def rebuild_native!
56
+ ensure_native!
57
+ @native_response = Spikard::Native.build_response(@content, @status_code, @headers)
58
+ return unless @native_response
59
+
60
+ @status_code = @native_response.status_code
61
+ @headers = @native_response.headers
62
+ end
63
+
64
+ def ensure_native!
65
+ return if defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_response)
66
+
67
+ raise 'Spikard native extension is not loaded'
68
+ end
69
+
45
70
  def cookie_parts(options)
46
71
  [
47
72
  options[:max_age] && "Max-Age=#{Integer(options[:max_age])}",
@@ -59,7 +84,7 @@ module Spikard
59
84
  {}
60
85
  when Hash
61
86
  value.each_with_object({}) do |(key, val), acc|
62
- acc[key.to_s] = val.to_s
87
+ acc[key.to_s.downcase] = val.to_s
63
88
  end
64
89
  else
65
90
  raise ArgumentError, 'headers must be a Hash'
@@ -67,9 +92,48 @@ module Spikard
67
92
  end
68
93
  end
69
94
 
95
+ class StreamingResponse # :nodoc: Streaming response wrapper backed by the native Rust builder.
96
+ attr_reader :stream, :status_code, :headers, :native_response
97
+
98
+ def initialize(stream, status_code: 200, headers: nil)
99
+ unless stream.respond_to?(:next) || stream.respond_to?(:each)
100
+ raise ArgumentError, 'StreamingResponse requires an object responding to #next or #each'
101
+ end
102
+
103
+ @stream = stream.respond_to?(:to_enum) ? stream.to_enum : stream
104
+ @status_code = Integer(status_code || 200)
105
+ header_hash = headers || {}
106
+ @headers = header_hash.each_with_object({}) do |(key, value), memo|
107
+ memo[String(key)] = String(value)
108
+ end
109
+
110
+ rebuild_native!
111
+ end
112
+
113
+ def to_native_response
114
+ @native_response
115
+ end
116
+
117
+ private
118
+
119
+ def rebuild_native!
120
+ ensure_native!
121
+ @native_response = Spikard::Native.build_streaming_response(@stream, @status_code, @headers)
122
+ return unless @native_response
123
+
124
+ @status_code = @native_response.status_code
125
+ @headers = @native_response.headers
126
+ end
127
+
128
+ def ensure_native!
129
+ return if defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_streaming_response)
130
+
131
+ raise 'Spikard native extension is not loaded'
132
+ end
133
+ end
134
+
70
135
  module Testing
71
- # Lightweight wrapper around native response hashes.
72
- class Response
136
+ class Response # :nodoc: Lightweight response wrapper used by the test client.
73
137
  attr_reader :status_code, :headers, :body
74
138
 
75
139
  def initialize(payload)
@@ -3,7 +3,7 @@
3
3
  module Spikard
4
4
  # Represents a streaming HTTP response made of chunks produced lazily.
5
5
  class StreamingResponse
6
- attr_reader :stream, :status_code, :headers
6
+ attr_reader :stream, :status_code, :headers, :native_response
7
7
 
8
8
  def initialize(stream, status_code: 200, headers: nil)
9
9
  unless stream.respond_to?(:next) || stream.respond_to?(:each)
@@ -16,6 +16,29 @@ module Spikard
16
16
  @headers = header_hash.each_with_object({}) do |(key, value), memo|
17
17
  memo[String(key)] = String(value)
18
18
  end
19
+
20
+ rebuild_native!
21
+ end
22
+
23
+ def to_native_response
24
+ @native_response
25
+ end
26
+
27
+ private
28
+
29
+ def rebuild_native!
30
+ ensure_native!
31
+ @native_response = Spikard::Native.build_streaming_response(@stream, @status_code, @headers)
32
+ return unless @native_response
33
+
34
+ @status_code = @native_response.status_code
35
+ @headers = @native_response.headers
36
+ end
37
+
38
+ def ensure_native!
39
+ return if defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_streaming_response)
40
+
41
+ raise 'Spikard native extension is not loaded'
19
42
  end
20
43
  end
21
44
  end
@@ -20,7 +20,7 @@ module Spikard
20
20
  # Use default config if none provided
21
21
  config ||= Spikard::ServerConfig.new
22
22
 
23
- routes_json = JSON.generate(app.route_metadata)
23
+ routes_json = app.normalized_routes_json
24
24
  handlers = app.handler_map.transform_keys(&:to_sym)
25
25
  ws_handlers = app.websocket_handlers || {}
26
26
  sse_producers = app.sse_producers || {}