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.
Files changed (138) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +1 -0
  3. data/README.md +659 -0
  4. data/ext/spikard_rb/Cargo.toml +17 -0
  5. data/ext/spikard_rb/extconf.rb +10 -0
  6. data/ext/spikard_rb/src/lib.rs +6 -0
  7. data/lib/spikard/app.rb +405 -0
  8. data/lib/spikard/background.rb +27 -0
  9. data/lib/spikard/config.rb +396 -0
  10. data/lib/spikard/converters.rb +13 -0
  11. data/lib/spikard/handler_wrapper.rb +113 -0
  12. data/lib/spikard/provide.rb +214 -0
  13. data/lib/spikard/response.rb +173 -0
  14. data/lib/spikard/schema.rb +243 -0
  15. data/lib/spikard/sse.rb +111 -0
  16. data/lib/spikard/streaming_response.rb +44 -0
  17. data/lib/spikard/testing.rb +221 -0
  18. data/lib/spikard/upload_file.rb +131 -0
  19. data/lib/spikard/version.rb +5 -0
  20. data/lib/spikard/websocket.rb +59 -0
  21. data/lib/spikard.rb +43 -0
  22. data/sig/spikard.rbs +366 -0
  23. data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +5 -0
  24. data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +2 -0
  25. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
  26. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +139 -0
  27. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +561 -0
  28. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
  29. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
  30. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +403 -0
  31. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +274 -0
  32. data/vendor/crates/spikard-bindings-shared/src/lib.rs +25 -0
  33. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +298 -0
  34. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +637 -0
  35. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +309 -0
  36. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
  37. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +355 -0
  38. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +502 -0
  39. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +389 -0
  40. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +413 -0
  41. data/vendor/crates/spikard-core/Cargo.toml +40 -0
  42. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
  43. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
  44. data/vendor/crates/spikard-core/src/debug.rs +63 -0
  45. data/vendor/crates/spikard-core/src/di/container.rs +726 -0
  46. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
  47. data/vendor/crates/spikard-core/src/di/error.rs +118 -0
  48. data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
  49. data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
  50. data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
  51. data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
  52. data/vendor/crates/spikard-core/src/di/value.rs +283 -0
  53. data/vendor/crates/spikard-core/src/errors.rs +39 -0
  54. data/vendor/crates/spikard-core/src/http.rs +153 -0
  55. data/vendor/crates/spikard-core/src/lib.rs +29 -0
  56. data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
  57. data/vendor/crates/spikard-core/src/metadata.rs +397 -0
  58. data/vendor/crates/spikard-core/src/parameters.rs +723 -0
  59. data/vendor/crates/spikard-core/src/problem.rs +310 -0
  60. data/vendor/crates/spikard-core/src/request_data.rs +189 -0
  61. data/vendor/crates/spikard-core/src/router.rs +249 -0
  62. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
  63. data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
  64. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +689 -0
  65. data/vendor/crates/spikard-core/src/validation/mod.rs +459 -0
  66. data/vendor/crates/spikard-http/Cargo.toml +58 -0
  67. data/vendor/crates/spikard-http/examples/sse-notifications.rs +147 -0
  68. data/vendor/crates/spikard-http/examples/websocket-chat.rs +91 -0
  69. data/vendor/crates/spikard-http/src/auth.rs +247 -0
  70. data/vendor/crates/spikard-http/src/background.rs +1562 -0
  71. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
  72. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
  73. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
  74. data/vendor/crates/spikard-http/src/cors.rs +490 -0
  75. data/vendor/crates/spikard-http/src/debug.rs +63 -0
  76. data/vendor/crates/spikard-http/src/di_handler.rs +1878 -0
  77. data/vendor/crates/spikard-http/src/handler_response.rs +532 -0
  78. data/vendor/crates/spikard-http/src/handler_trait.rs +861 -0
  79. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
  80. data/vendor/crates/spikard-http/src/lib.rs +524 -0
  81. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
  82. data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
  83. data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
  84. data/vendor/crates/spikard-http/src/middleware/multipart.rs +930 -0
  85. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +541 -0
  86. data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
  87. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
  88. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -0
  89. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +867 -0
  90. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +678 -0
  91. data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
  92. data/vendor/crates/spikard-http/src/response.rs +399 -0
  93. data/vendor/crates/spikard-http/src/server/handler.rs +1557 -0
  94. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
  95. data/vendor/crates/spikard-http/src/server/mod.rs +806 -0
  96. data/vendor/crates/spikard-http/src/server/request_extraction.rs +630 -0
  97. data/vendor/crates/spikard-http/src/server/routing_factory.rs +497 -0
  98. data/vendor/crates/spikard-http/src/sse.rs +961 -0
  99. data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
  100. data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
  101. data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
  102. data/vendor/crates/spikard-http/src/testing.rs +377 -0
  103. data/vendor/crates/spikard-http/src/websocket.rs +831 -0
  104. data/vendor/crates/spikard-http/tests/background_behavior.rs +918 -0
  105. data/vendor/crates/spikard-http/tests/common/handlers.rs +308 -0
  106. data/vendor/crates/spikard-http/tests/common/mod.rs +21 -0
  107. data/vendor/crates/spikard-http/tests/di_integration.rs +202 -0
  108. data/vendor/crates/spikard-http/tests/doc_snippets.rs +4 -0
  109. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1135 -0
  110. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +688 -0
  111. data/vendor/crates/spikard-http/tests/server_config_builder.rs +324 -0
  112. data/vendor/crates/spikard-http/tests/sse_behavior.rs +728 -0
  113. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +724 -0
  114. data/vendor/crates/spikard-rb/Cargo.toml +43 -0
  115. data/vendor/crates/spikard-rb/build.rs +199 -0
  116. data/vendor/crates/spikard-rb/src/background.rs +63 -0
  117. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
  118. data/vendor/crates/spikard-rb/src/config/server_config.rs +283 -0
  119. data/vendor/crates/spikard-rb/src/conversion.rs +459 -0
  120. data/vendor/crates/spikard-rb/src/di/builder.rs +105 -0
  121. data/vendor/crates/spikard-rb/src/di/mod.rs +413 -0
  122. data/vendor/crates/spikard-rb/src/handler.rs +612 -0
  123. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
  124. data/vendor/crates/spikard-rb/src/lib.rs +1857 -0
  125. data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -0
  126. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
  127. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +427 -0
  128. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
  129. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +326 -0
  130. data/vendor/crates/spikard-rb/src/server.rs +283 -0
  131. data/vendor/crates/spikard-rb/src/sse.rs +231 -0
  132. data/vendor/crates/spikard-rb/src/testing/client.rs +404 -0
  133. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
  134. data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -0
  135. data/vendor/crates/spikard-rb/src/testing/websocket.rs +221 -0
  136. data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
  137. data/vendor/crates/spikard-rb/tests/magnus_ffi_tests.rs +14 -0
  138. 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