spikard 0.4.0-x64-mingw-ucrt
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 +7 -0
- data/LICENSE +1 -0
- data/README.md +659 -0
- data/ext/spikard_rb/Cargo.toml +17 -0
- data/ext/spikard_rb/extconf.rb +10 -0
- data/ext/spikard_rb/src/lib.rs +6 -0
- data/lib/spikard/app.rb +405 -0
- data/lib/spikard/background.rb +27 -0
- data/lib/spikard/config.rb +396 -0
- data/lib/spikard/converters.rb +13 -0
- data/lib/spikard/handler_wrapper.rb +113 -0
- data/lib/spikard/provide.rb +214 -0
- data/lib/spikard/response.rb +173 -0
- data/lib/spikard/schema.rb +243 -0
- data/lib/spikard/sse.rb +111 -0
- data/lib/spikard/streaming_response.rb +44 -0
- data/lib/spikard/testing.rb +221 -0
- data/lib/spikard/upload_file.rb +131 -0
- data/lib/spikard/version.rb +5 -0
- data/lib/spikard/websocket.rb +59 -0
- data/lib/spikard.rb +43 -0
- data/sig/spikard.rbs +366 -0
- data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +5 -0
- data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +2 -0
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
- data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +139 -0
- data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +561 -0
- data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
- data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
- data/vendor/crates/spikard-bindings-shared/src/error_response.rs +403 -0
- data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +274 -0
- data/vendor/crates/spikard-bindings-shared/src/lib.rs +25 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +298 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +637 -0
- data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +309 -0
- data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
- data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +355 -0
- data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +502 -0
- data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +389 -0
- data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +413 -0
- data/vendor/crates/spikard-core/Cargo.toml +40 -0
- data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
- data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
- data/vendor/crates/spikard-core/src/debug.rs +63 -0
- data/vendor/crates/spikard-core/src/di/container.rs +726 -0
- data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
- data/vendor/crates/spikard-core/src/di/error.rs +118 -0
- data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
- data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
- data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
- data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
- data/vendor/crates/spikard-core/src/di/value.rs +283 -0
- data/vendor/crates/spikard-core/src/errors.rs +39 -0
- data/vendor/crates/spikard-core/src/http.rs +153 -0
- data/vendor/crates/spikard-core/src/lib.rs +29 -0
- data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
- data/vendor/crates/spikard-core/src/metadata.rs +397 -0
- data/vendor/crates/spikard-core/src/parameters.rs +723 -0
- data/vendor/crates/spikard-core/src/problem.rs +310 -0
- data/vendor/crates/spikard-core/src/request_data.rs +189 -0
- data/vendor/crates/spikard-core/src/router.rs +249 -0
- data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
- data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
- data/vendor/crates/spikard-core/src/validation/error_mapper.rs +689 -0
- data/vendor/crates/spikard-core/src/validation/mod.rs +459 -0
- data/vendor/crates/spikard-http/Cargo.toml +58 -0
- data/vendor/crates/spikard-http/examples/sse-notifications.rs +147 -0
- data/vendor/crates/spikard-http/examples/websocket-chat.rs +91 -0
- data/vendor/crates/spikard-http/src/auth.rs +247 -0
- data/vendor/crates/spikard-http/src/background.rs +1562 -0
- data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
- data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
- data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
- data/vendor/crates/spikard-http/src/cors.rs +490 -0
- data/vendor/crates/spikard-http/src/debug.rs +63 -0
- data/vendor/crates/spikard-http/src/di_handler.rs +1878 -0
- data/vendor/crates/spikard-http/src/handler_response.rs +532 -0
- data/vendor/crates/spikard-http/src/handler_trait.rs +861 -0
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
- data/vendor/crates/spikard-http/src/lib.rs +524 -0
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
- data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
- data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
- data/vendor/crates/spikard-http/src/middleware/multipart.rs +930 -0
- data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +541 -0
- data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
- data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
- data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -0
- data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +867 -0
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +678 -0
- data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
- data/vendor/crates/spikard-http/src/response.rs +399 -0
- data/vendor/crates/spikard-http/src/server/handler.rs +1557 -0
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
- data/vendor/crates/spikard-http/src/server/mod.rs +806 -0
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +630 -0
- data/vendor/crates/spikard-http/src/server/routing_factory.rs +497 -0
- data/vendor/crates/spikard-http/src/sse.rs +961 -0
- data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
- data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
- data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
- data/vendor/crates/spikard-http/src/testing.rs +377 -0
- data/vendor/crates/spikard-http/src/websocket.rs +831 -0
- data/vendor/crates/spikard-http/tests/background_behavior.rs +918 -0
- data/vendor/crates/spikard-http/tests/common/handlers.rs +308 -0
- data/vendor/crates/spikard-http/tests/common/mod.rs +21 -0
- data/vendor/crates/spikard-http/tests/di_integration.rs +202 -0
- data/vendor/crates/spikard-http/tests/doc_snippets.rs +4 -0
- data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1135 -0
- data/vendor/crates/spikard-http/tests/multipart_behavior.rs +688 -0
- data/vendor/crates/spikard-http/tests/server_config_builder.rs +324 -0
- data/vendor/crates/spikard-http/tests/sse_behavior.rs +728 -0
- data/vendor/crates/spikard-http/tests/websocket_behavior.rs +724 -0
- data/vendor/crates/spikard-rb/Cargo.toml +43 -0
- data/vendor/crates/spikard-rb/build.rs +199 -0
- data/vendor/crates/spikard-rb/src/background.rs +63 -0
- data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/config/server_config.rs +283 -0
- data/vendor/crates/spikard-rb/src/conversion.rs +459 -0
- data/vendor/crates/spikard-rb/src/di/builder.rs +105 -0
- data/vendor/crates/spikard-rb/src/di/mod.rs +413 -0
- data/vendor/crates/spikard-rb/src/handler.rs +612 -0
- data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
- data/vendor/crates/spikard-rb/src/lib.rs +1857 -0
- data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -0
- data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +427 -0
- data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +326 -0
- data/vendor/crates/spikard-rb/src/server.rs +283 -0
- data/vendor/crates/spikard-rb/src/sse.rs +231 -0
- data/vendor/crates/spikard-rb/src/testing/client.rs +404 -0
- data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
- data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -0
- data/vendor/crates/spikard-rb/src/testing/websocket.rs +221 -0
- data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
- data/vendor/crates/spikard-rb/tests/magnus_ffi_tests.rs +14 -0
- metadata +213 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spikard
|
|
4
|
+
# Wrapper class for dependency providers
|
|
5
|
+
#
|
|
6
|
+
# This class wraps factory functions and configuration for dependency injection.
|
|
7
|
+
# It provides a consistent API across Python, Node.js, and Ruby bindings.
|
|
8
|
+
#
|
|
9
|
+
# @example Factory with caching
|
|
10
|
+
# app.provide("db", Spikard::Provide.new(method("create_db"), cacheable: true))
|
|
11
|
+
#
|
|
12
|
+
# @example Factory with dependencies
|
|
13
|
+
# app.provide("auth", Spikard::Provide.new(
|
|
14
|
+
# method("create_auth_service"),
|
|
15
|
+
# depends_on: ["db", "cache"],
|
|
16
|
+
# singleton: true
|
|
17
|
+
# ))
|
|
18
|
+
class Provide
|
|
19
|
+
attr_reader :factory, :depends_on, :singleton, :cacheable
|
|
20
|
+
|
|
21
|
+
# Create a new dependency provider
|
|
22
|
+
#
|
|
23
|
+
# @param factory [Proc, Method] The factory function that creates the dependency value
|
|
24
|
+
# @param depends_on [Array<String, Symbol>] List of dependency keys this factory depends on
|
|
25
|
+
# @param singleton [Boolean] Whether to cache the value globally (default: false)
|
|
26
|
+
# @param cacheable [Boolean] Whether to cache the value per-request (default: true)
|
|
27
|
+
def initialize(factory, depends_on: [], singleton: false, cacheable: true)
|
|
28
|
+
@factory = factory
|
|
29
|
+
@depends_on = Array(depends_on).map(&:to_s)
|
|
30
|
+
@singleton = singleton
|
|
31
|
+
@cacheable = cacheable
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if the factory is async (based on method arity or other heuristics)
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean] True if the factory appears to be async
|
|
37
|
+
def async?
|
|
38
|
+
# Ruby doesn't have explicit async/await like Python/JS
|
|
39
|
+
# We could check if it returns a Thread or uses Fiber
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if the factory is an async generator
|
|
44
|
+
#
|
|
45
|
+
# @return [Boolean] True if the factory is an async generator
|
|
46
|
+
def async_generator?
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Dependency Injection support for Spikard applications
|
|
52
|
+
#
|
|
53
|
+
# Provides methods for registering and managing dependencies that can be
|
|
54
|
+
# automatically injected into route handlers.
|
|
55
|
+
#
|
|
56
|
+
# @example Registering a value dependency
|
|
57
|
+
# app.provide("database_url", "postgresql://localhost/mydb")
|
|
58
|
+
#
|
|
59
|
+
# @example Registering a factory dependency
|
|
60
|
+
# app.provide("db_pool", depends_on: ["database_url"]) do |database_url:|
|
|
61
|
+
# ConnectionPool.new(database_url)
|
|
62
|
+
# end
|
|
63
|
+
#
|
|
64
|
+
# @example Singleton dependency (shared across all requests)
|
|
65
|
+
# app.provide("config", singleton: true) do
|
|
66
|
+
# Config.load_from_file("config.yml")
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
# @example Using Provide wrapper
|
|
70
|
+
# app.provide("db", Spikard::Provide.new(method("create_db"), cacheable: true))
|
|
71
|
+
module ProvideSupport
|
|
72
|
+
# Register a dependency in the DI container
|
|
73
|
+
#
|
|
74
|
+
# This method supports three patterns:
|
|
75
|
+
# 1. **Value dependency**: Pass a value directly (e.g., string, number, object)
|
|
76
|
+
# 2. **Factory dependency**: Pass a block that computes the value
|
|
77
|
+
# 3. **Provide wrapper**: Pass a Spikard::Provide instance
|
|
78
|
+
#
|
|
79
|
+
# @param key [String, Symbol] Unique identifier for the dependency
|
|
80
|
+
# @param value [Object, Provide, nil] Static value, Provide instance, or nil
|
|
81
|
+
# @param depends_on [Array<String, Symbol>] List of dependency keys this factory depends on
|
|
82
|
+
# @param singleton [Boolean] Whether to cache the value globally (default: false)
|
|
83
|
+
# @param cacheable [Boolean] Whether to cache the value per-request (default: true)
|
|
84
|
+
# @yield Optional factory block that receives dependencies as keyword arguments
|
|
85
|
+
# @yieldparam **deps [Hash] Resolved dependencies as keyword arguments
|
|
86
|
+
# @yieldreturn [Object] The computed dependency value
|
|
87
|
+
# @return [self] Returns self for method chaining
|
|
88
|
+
#
|
|
89
|
+
# @example Value dependency
|
|
90
|
+
# app.provide("app_name", "MyApp")
|
|
91
|
+
# app.provide("port", 8080)
|
|
92
|
+
#
|
|
93
|
+
# @example Factory with dependencies
|
|
94
|
+
# app.provide("database", depends_on: ["config"]) do |config:|
|
|
95
|
+
# Database.connect(config["db_url"])
|
|
96
|
+
# end
|
|
97
|
+
#
|
|
98
|
+
# @example Singleton factory
|
|
99
|
+
# app.provide("thread_pool", singleton: true) do
|
|
100
|
+
# ThreadPool.new(size: 10)
|
|
101
|
+
# end
|
|
102
|
+
#
|
|
103
|
+
# @example Non-cacheable factory (resolves every time)
|
|
104
|
+
# app.provide("request_id", cacheable: false) do
|
|
105
|
+
# SecureRandom.uuid
|
|
106
|
+
# end
|
|
107
|
+
#
|
|
108
|
+
# @example Using Provide wrapper
|
|
109
|
+
# app.provide("db", Spikard::Provide.new(method("create_db"), cacheable: true))
|
|
110
|
+
def provide(key, value = nil, depends_on: [], singleton: false, cacheable: true, &block)
|
|
111
|
+
key_str = key.to_s
|
|
112
|
+
registry = ensure_native_dependencies!
|
|
113
|
+
|
|
114
|
+
# Handle Provide wrapper instances
|
|
115
|
+
if value.is_a?(Provide)
|
|
116
|
+
registry.register_factory(key_str, value.factory, value.depends_on, value.singleton, value.cacheable)
|
|
117
|
+
elsif block
|
|
118
|
+
registry.register_factory(key_str, block, Array(depends_on).map(&:to_s), singleton, cacheable)
|
|
119
|
+
else
|
|
120
|
+
raise ArgumentError, 'Either provide a value or a block, not both' if value.nil?
|
|
121
|
+
|
|
122
|
+
registry.register_value(key_str, value)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Get all registered dependencies
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash] Dictionary mapping dependency keys to their definitions
|
|
131
|
+
# @api private
|
|
132
|
+
def dependencies
|
|
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
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Dependency injection handler wrapper
|
|
147
|
+
#
|
|
148
|
+
# Wraps a route handler to inject dependencies based on parameter names.
|
|
149
|
+
# Dependencies are resolved from the DI container and passed as keyword arguments.
|
|
150
|
+
#
|
|
151
|
+
# @api private
|
|
152
|
+
module DIHandlerWrapper
|
|
153
|
+
# Wrap a handler to inject dependencies
|
|
154
|
+
#
|
|
155
|
+
# @param handler [Proc] The original route handler
|
|
156
|
+
# @param dependencies [Hash] Available dependencies from the app
|
|
157
|
+
# @return [Proc] Wrapped handler with DI support
|
|
158
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
159
|
+
def self.wrap_handler(handler, dependencies)
|
|
160
|
+
# Extract parameter names from the handler
|
|
161
|
+
params = handler.parameters.map { |_type, name| name.to_s }
|
|
162
|
+
|
|
163
|
+
# Find which parameters match registered dependencies
|
|
164
|
+
injectable_params = params & dependencies.keys
|
|
165
|
+
|
|
166
|
+
if injectable_params.empty?
|
|
167
|
+
# No DI needed, return original handler
|
|
168
|
+
return handler
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Create wrapped handler that injects dependencies
|
|
172
|
+
lambda do |request|
|
|
173
|
+
# Build kwargs with injected dependencies
|
|
174
|
+
kwargs = {}
|
|
175
|
+
|
|
176
|
+
injectable_params.each do |param_name|
|
|
177
|
+
dep_def = dependencies[param_name]
|
|
178
|
+
kwargs[param_name.to_sym] = resolve_dependency(dep_def, request)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Call original handler with injected dependencies
|
|
182
|
+
if handler.arity.zero?
|
|
183
|
+
# Handler takes no arguments (dependencies injected via closure or instance vars)
|
|
184
|
+
handler.call
|
|
185
|
+
elsif injectable_params.length == params.length
|
|
186
|
+
# All parameters are dependencies
|
|
187
|
+
handler.call(**kwargs)
|
|
188
|
+
else
|
|
189
|
+
# Mix of request data and dependencies
|
|
190
|
+
handler.call(request, **kwargs)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
195
|
+
|
|
196
|
+
# Resolve a dependency definition
|
|
197
|
+
#
|
|
198
|
+
# @param dep_def [Hash] Dependency definition
|
|
199
|
+
# @param request [Hash] Request context (unused for now, future: per-request deps)
|
|
200
|
+
# @return [Object] Resolved dependency value
|
|
201
|
+
# @api private
|
|
202
|
+
def self.resolve_dependency(dep_def, _request)
|
|
203
|
+
case dep_def[:type]
|
|
204
|
+
when :value
|
|
205
|
+
dep_def[:value]
|
|
206
|
+
when :factory
|
|
207
|
+
factory = dep_def[:factory]
|
|
208
|
+
dep_def[:depends_on]
|
|
209
|
+
# TODO: Implement nested dependency resolution when dependencies are provided
|
|
210
|
+
factory.call
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ⚠️ GENERATED BY crates/spikard-rb/build.rs — DO NOT EDIT BY HAND
|
|
4
|
+
module Spikard
|
|
5
|
+
class Response # :nodoc: Native-backed HTTP response facade generated from Rust metadata.
|
|
6
|
+
attr_reader :content, :status_code, :headers, :native_response
|
|
7
|
+
|
|
8
|
+
def initialize(content: nil, body: nil, status_code: 200, headers: nil, content_type: nil)
|
|
9
|
+
@content = content.nil? ? body : content
|
|
10
|
+
@status_code = Integer(status_code || 200)
|
|
11
|
+
@headers = normalize_headers(headers)
|
|
12
|
+
set_header('content-type', content_type) if content_type
|
|
13
|
+
rebuild_native!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def status
|
|
17
|
+
@status_code
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def status_code=(value)
|
|
21
|
+
@status_code = Integer(value)
|
|
22
|
+
rebuild_native!
|
|
23
|
+
rescue ArgumentError, TypeError
|
|
24
|
+
raise ArgumentError, 'status_code must be an integer'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def headers=(value)
|
|
28
|
+
@headers = normalize_headers(value)
|
|
29
|
+
rebuild_native!
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def content=(value)
|
|
33
|
+
@content = value
|
|
34
|
+
rebuild_native!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def set_header(name, value)
|
|
38
|
+
@headers[name.to_s] = value.to_s
|
|
39
|
+
rebuild_native!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set_cookie(name, value, **options)
|
|
43
|
+
raise ArgumentError, 'cookie name required' if name.nil? || name.empty?
|
|
44
|
+
|
|
45
|
+
header_value = ["#{name}=#{value}", *cookie_parts(options)].join('; ')
|
|
46
|
+
set_header('set-cookie', header_value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_native_response
|
|
50
|
+
@native_response
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
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
|
+
|
|
70
|
+
def cookie_parts(options)
|
|
71
|
+
[
|
|
72
|
+
options[:max_age] && "Max-Age=#{Integer(options[:max_age])}",
|
|
73
|
+
options[:domain] && "Domain=#{options[:domain]}",
|
|
74
|
+
"Path=#{options.fetch(:path, '/') || '/'}",
|
|
75
|
+
options[:secure] ? 'Secure' : nil,
|
|
76
|
+
options[:httponly] ? 'HttpOnly' : nil,
|
|
77
|
+
options[:samesite] && "SameSite=#{options[:samesite]}"
|
|
78
|
+
].compact
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_headers(value)
|
|
82
|
+
case value
|
|
83
|
+
when nil
|
|
84
|
+
{}
|
|
85
|
+
when Hash
|
|
86
|
+
value.each_with_object({}) do |(key, val), acc|
|
|
87
|
+
acc[key.to_s.downcase] = val.to_s
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
raise ArgumentError, 'headers must be a Hash'
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
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
|
+
|
|
135
|
+
module Testing
|
|
136
|
+
class Response # :nodoc: Lightweight response wrapper used by the test client.
|
|
137
|
+
attr_reader :status_code, :headers, :body
|
|
138
|
+
|
|
139
|
+
def initialize(payload)
|
|
140
|
+
@status_code = payload[:status_code]
|
|
141
|
+
@headers = payload[:headers] || {}
|
|
142
|
+
@body = payload[:body]
|
|
143
|
+
@body_text = payload[:body_text]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def status
|
|
147
|
+
@status_code
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def body_bytes
|
|
151
|
+
@body || ''.b
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def body_text
|
|
155
|
+
@body_text || @body&.dup&.force_encoding(Encoding::UTF_8)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def text
|
|
159
|
+
body_text
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def json
|
|
163
|
+
return nil if @body.nil? || @body.empty?
|
|
164
|
+
|
|
165
|
+
JSON.parse(@body)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def bytes
|
|
169
|
+
body_bytes.bytes
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ModuleLength
|
|
4
|
+
module Spikard
|
|
5
|
+
# Schema extraction helpers for Ruby type systems
|
|
6
|
+
#
|
|
7
|
+
# Supports:
|
|
8
|
+
# - Plain JSON Schema (Hash)
|
|
9
|
+
# - Dry::Schema with :json_schema extension
|
|
10
|
+
# - Dry::Struct (Dry-Types)
|
|
11
|
+
#
|
|
12
|
+
# @example With Dry::Schema
|
|
13
|
+
# require 'dry-schema'
|
|
14
|
+
# Dry::Schema.load_extensions(:json_schema)
|
|
15
|
+
#
|
|
16
|
+
# UserSchema = Dry::Schema.JSON do
|
|
17
|
+
# required(:email).filled(:str?)
|
|
18
|
+
# required(:age).filled(:int?)
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# schema = Spikard::Schema.extract_json_schema(UserSchema)
|
|
22
|
+
#
|
|
23
|
+
# @example With Dry::Struct
|
|
24
|
+
# require 'dry-struct'
|
|
25
|
+
#
|
|
26
|
+
# class User < Dry::Struct
|
|
27
|
+
# attribute :email, Types::String
|
|
28
|
+
# attribute :age, Types::Integer
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# schema = Spikard::Schema.extract_json_schema(User)
|
|
32
|
+
#
|
|
33
|
+
# @example With plain JSON Schema
|
|
34
|
+
# schema_hash = {
|
|
35
|
+
# "type" => "object",
|
|
36
|
+
# "properties" => {
|
|
37
|
+
# "email" => { "type" => "string" },
|
|
38
|
+
# "age" => { "type" => "integer" }
|
|
39
|
+
# },
|
|
40
|
+
# "required" => ["email", "age"]
|
|
41
|
+
# }
|
|
42
|
+
#
|
|
43
|
+
# schema = Spikard::Schema.extract_json_schema(schema_hash)
|
|
44
|
+
module Schema
|
|
45
|
+
# rubocop:disable Metrics/ClassLength
|
|
46
|
+
class << self
|
|
47
|
+
# Extract JSON Schema from various Ruby schema sources
|
|
48
|
+
#
|
|
49
|
+
# @param schema_source [Object] The schema source (Hash, Dry::Schema, Dry::Struct class)
|
|
50
|
+
# @return [Hash, nil] JSON Schema hash or nil if extraction fails
|
|
51
|
+
def extract_json_schema(schema_source)
|
|
52
|
+
return nil if schema_source.nil?
|
|
53
|
+
|
|
54
|
+
# 1. Check if plain JSON Schema hash
|
|
55
|
+
return schema_source if schema_source.is_a?(Hash) && json_schema_hash?(schema_source)
|
|
56
|
+
|
|
57
|
+
# 2. Check for Dry::Schema with json_schema extension
|
|
58
|
+
return extract_from_dry_schema(schema_source) if dry_schema?(schema_source)
|
|
59
|
+
|
|
60
|
+
# 3. Check for Dry::Struct (Dry-Types)
|
|
61
|
+
return extract_from_dry_struct(schema_source) if dry_struct_class?(schema_source)
|
|
62
|
+
|
|
63
|
+
# 4. Unknown type
|
|
64
|
+
warn "Spikard: Unable to extract JSON Schema from #{schema_source.class}. " \
|
|
65
|
+
'Supported types: Hash, Dry::Schema, Dry::Struct'
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Check if object is a plain JSON Schema hash
|
|
72
|
+
def json_schema_hash?(obj)
|
|
73
|
+
return false unless obj.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
# Must have 'type' key or '$schema' key
|
|
76
|
+
obj.key?('type') || obj.key?('$schema') || obj.key?(:type) || obj.key?(:$schema)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check if object is a Dry::Schema
|
|
80
|
+
def dry_schema?(obj)
|
|
81
|
+
defined?(Dry::Schema::Processor) && obj.is_a?(Dry::Schema::Processor)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if object is a Dry::Struct class
|
|
85
|
+
def dry_struct_class?(obj)
|
|
86
|
+
return false unless obj.is_a?(Class)
|
|
87
|
+
|
|
88
|
+
defined?(Dry::Struct) && obj < Dry::Struct
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Extract JSON Schema from Dry::Schema
|
|
92
|
+
def extract_from_dry_schema(schema)
|
|
93
|
+
unless schema.respond_to?(:json_schema)
|
|
94
|
+
warn 'Spikard: Dry::Schema instance does not have json_schema method. ' \
|
|
95
|
+
'Did you load the :json_schema extension? ' \
|
|
96
|
+
'Add: Dry::Schema.load_extensions(:json_schema)'
|
|
97
|
+
return nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
schema.json_schema
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
warn "Spikard: Failed to extract JSON Schema from Dry::Schema: #{e.message}"
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Extract JSON Schema from Dry::Struct class
|
|
109
|
+
# rubocop:disable Metrics/MethodLength
|
|
110
|
+
def extract_from_dry_struct(struct_class)
|
|
111
|
+
# Dry::Struct doesn't have built-in JSON Schema export
|
|
112
|
+
# We need to manually build it from the attribute schema
|
|
113
|
+
|
|
114
|
+
properties = {}
|
|
115
|
+
required = []
|
|
116
|
+
|
|
117
|
+
struct_class.schema.each do |key, type_definition|
|
|
118
|
+
# Extract attribute name
|
|
119
|
+
attr_name = key.to_s
|
|
120
|
+
|
|
121
|
+
# Determine if required (non-optional)
|
|
122
|
+
is_required = !type_definition.optional?
|
|
123
|
+
required << attr_name if is_required
|
|
124
|
+
|
|
125
|
+
# Convert Dry::Types to JSON Schema type
|
|
126
|
+
json_type = dry_type_to_json_schema(type_definition)
|
|
127
|
+
properties[attr_name] = json_type if json_type
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
'type' => 'object',
|
|
132
|
+
'properties' => properties,
|
|
133
|
+
'required' => required
|
|
134
|
+
}
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
warn "Spikard: Failed to extract JSON Schema from Dry::Struct: #{e.message}"
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
# rubocop:enable Metrics/MethodLength
|
|
140
|
+
|
|
141
|
+
# Convert Dry::Types type to JSON Schema type
|
|
142
|
+
def dry_type_to_json_schema(type_def)
|
|
143
|
+
schema = base_schema_for(type_def)
|
|
144
|
+
apply_metadata_constraints(schema, type_def)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
{ 'type' => 'object' }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# rubocop:disable Metrics/MethodLength
|
|
150
|
+
def base_schema_for(type_def)
|
|
151
|
+
type_class = type_def.primitive.to_s
|
|
152
|
+
case type_class
|
|
153
|
+
when 'String' then { 'type' => 'string' }
|
|
154
|
+
when 'Integer' then { 'type' => 'integer' }
|
|
155
|
+
when 'Float', 'BigDecimal' then { 'type' => 'number' }
|
|
156
|
+
when 'TrueClass', 'FalseClass' then { 'type' => 'boolean' }
|
|
157
|
+
when 'Array'
|
|
158
|
+
{
|
|
159
|
+
'type' => 'array',
|
|
160
|
+
'items' => infer_array_items_schema(type_def)
|
|
161
|
+
}
|
|
162
|
+
when 'Hash'
|
|
163
|
+
{ 'type' => 'object', 'additionalProperties' => true }
|
|
164
|
+
when 'NilClass' then { 'type' => 'null' }
|
|
165
|
+
else
|
|
166
|
+
{ 'type' => 'object' }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
# rubocop:enable Metrics/MethodLength
|
|
170
|
+
|
|
171
|
+
def infer_array_items_schema(type_def)
|
|
172
|
+
if type_def.respond_to?(:member) && type_def.member
|
|
173
|
+
dry_type_to_json_schema(type_def.member)
|
|
174
|
+
else
|
|
175
|
+
{}
|
|
176
|
+
end
|
|
177
|
+
rescue StandardError
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def apply_metadata_constraints(schema, type_def)
|
|
182
|
+
metadata = extract_metadata(type_def)
|
|
183
|
+
return schema if metadata.empty?
|
|
184
|
+
|
|
185
|
+
schema = apply_enum_and_format(schema, metadata)
|
|
186
|
+
apply_numeric_constraints(schema, metadata)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def apply_enum_and_format(schema, metadata)
|
|
190
|
+
enum_values = metadata[:enum] || metadata['enum']
|
|
191
|
+
schema['enum'] = Array(enum_values) if enum_values
|
|
192
|
+
|
|
193
|
+
format_value = metadata[:format] || metadata['format']
|
|
194
|
+
schema['format'] = format_value.to_s if format_value
|
|
195
|
+
|
|
196
|
+
description = metadata[:description] || metadata['description']
|
|
197
|
+
schema['description'] = description.to_s if description
|
|
198
|
+
schema
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# rubocop:disable Metrics/MethodLength
|
|
202
|
+
def apply_numeric_constraints(schema, metadata)
|
|
203
|
+
mapping = {
|
|
204
|
+
min_size: 'minLength',
|
|
205
|
+
max_size: 'maxLength',
|
|
206
|
+
min_items: 'minItems',
|
|
207
|
+
max_items: 'maxItems',
|
|
208
|
+
min: 'minimum',
|
|
209
|
+
max: 'maximum',
|
|
210
|
+
gte: 'minimum',
|
|
211
|
+
lte: 'maximum',
|
|
212
|
+
gt: 'exclusiveMinimum',
|
|
213
|
+
lt: 'exclusiveMaximum'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
mapping.each do |meta_key, json_key|
|
|
217
|
+
value = metadata[meta_key] || metadata[meta_key.to_s]
|
|
218
|
+
next unless value
|
|
219
|
+
|
|
220
|
+
schema[json_key] = value
|
|
221
|
+
end
|
|
222
|
+
schema
|
|
223
|
+
end
|
|
224
|
+
# rubocop:enable Metrics/MethodLength
|
|
225
|
+
|
|
226
|
+
def extract_metadata(type_def)
|
|
227
|
+
return {} unless type_def.respond_to?(:meta) || type_def.respond_to?(:options)
|
|
228
|
+
|
|
229
|
+
if type_def.respond_to?(:meta) && type_def.meta
|
|
230
|
+
type_def.meta
|
|
231
|
+
elsif type_def.respond_to?(:options) && type_def.options.is_a?(Hash)
|
|
232
|
+
type_def.options
|
|
233
|
+
else
|
|
234
|
+
{}
|
|
235
|
+
end
|
|
236
|
+
rescue StandardError
|
|
237
|
+
{}
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
# rubocop:enable Metrics/ClassLength
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
# rubocop:enable Metrics/ModuleLength
|