spikard 0.1.2 → 0.2.1

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.
@@ -1,85 +1,85 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'upload_file'
4
-
5
- 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.
10
- module Converters
11
- module_function
12
-
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
81
- def convert_handler_body(body)
82
- process_upload_file_fields(body)
83
- end
84
- end
85
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'upload_file'
4
+
5
+ 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.
10
+ module Converters
11
+ module_function
12
+
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
81
+ def convert_handler_body(body)
82
+ process_upload_file_fields(body)
83
+ end
84
+ end
85
+ end
@@ -1,116 +1,116 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'converters'
4
-
5
- module Spikard
6
- # Handler wrapper utilities for automatic file metadata conversion
7
- #
8
- # Provides ergonomic handler patterns that automatically convert
9
- # file metadata to UploadFile instances, eliminating boilerplate.
10
- #
11
- # @example Basic usage with body only
12
- # app.post('/upload', &wrap_body_handler do |body|
13
- # {
14
- # filename: body[:file].filename,
15
- # content: body[:file].read
16
- # }
17
- # end)
18
- #
19
- # @example With all parameters
20
- # app.post('/upload', &wrap_handler do |params, query, body|
21
- # {
22
- # id: params[:id],
23
- # search: query[:q],
24
- # file: body[:file].filename
25
- # }
26
- # end)
27
- module HandlerWrapper
28
- module_function
29
-
30
- # Wrap a handler that receives only the request body
31
- #
32
- # Automatically converts file metadata in the body to UploadFile instances.
33
- #
34
- # @yield [body] Handler block that receives converted body
35
- # @yieldparam body [Hash] Request body with file metadata converted to UploadFile
36
- # @yieldreturn [Hash, Spikard::Response] Response data or Response object
37
- # @return [Proc] Wrapped handler proc
38
- #
39
- # @example
40
- # app.post('/upload', &wrap_body_handler do |body|
41
- # { filename: body[:file].filename }
42
- # end)
43
- def wrap_body_handler(&handler)
44
- raise ArgumentError, 'block required for wrap_body_handler' unless handler
45
-
46
- # Return a proc that matches the signature expected by Spikard::App
47
- # The actual handler receives path params, query params, and body from Rust
48
- lambda do |_params, _query, body|
49
- converted_body = Converters.convert_handler_body(body)
50
- handler.call(converted_body)
51
- end
52
- end
53
-
54
- # Wrap a handler that receives path params, query params, and body
55
- #
56
- # Automatically converts file metadata in the body to UploadFile instances.
57
- #
58
- # @yield [params, query, body] Handler block that receives all request data
59
- # @yieldparam params [Hash] Path parameters
60
- # @yieldparam query [Hash] Query parameters
61
- # @yieldparam body [Hash] Request body with file metadata converted to UploadFile
62
- # @yieldreturn [Hash, Spikard::Response] Response data or Response object
63
- # @return [Proc] Wrapped handler proc
64
- #
65
- # @example
66
- # app.post('/users/{id}/upload', &wrap_handler do |params, query, body|
67
- # {
68
- # user_id: params[:id],
69
- # description: query[:desc],
70
- # file: body[:file].filename
71
- # }
72
- # end)
73
- def wrap_handler(&handler)
74
- raise ArgumentError, 'block required for wrap_handler' unless handler
75
-
76
- lambda do |params, query, body|
77
- converted_body = Converters.convert_handler_body(body)
78
- handler.call(params, query, converted_body)
79
- end
80
- end
81
-
82
- # Wrap a handler that receives a context hash with all request data
83
- #
84
- # Automatically converts file metadata in the body to UploadFile instances.
85
- # Useful when you want all request data in a single hash.
86
- #
87
- # @yield [context] Handler block that receives context hash
88
- # @yieldparam context [Hash] Request context with:
89
- # - :params [Hash] Path parameters
90
- # - :query [Hash] Query parameters
91
- # - :body [Hash] Request body with file metadata converted to UploadFile
92
- # @yieldreturn [Hash, Spikard::Response] Response data or Response object
93
- # @return [Proc] Wrapped handler proc
94
- #
95
- # @example
96
- # app.post('/upload', &wrap_handler_with_context do |ctx|
97
- # {
98
- # file: ctx[:body][:file].filename,
99
- # query_params: ctx[:query]
100
- # }
101
- # end)
102
- def wrap_handler_with_context(&handler)
103
- raise ArgumentError, 'block required for wrap_handler_with_context' unless handler
104
-
105
- lambda do |params, query, body|
106
- converted_body = Converters.convert_handler_body(body)
107
- context = {
108
- params: params,
109
- query: query,
110
- body: converted_body
111
- }
112
- handler.call(context)
113
- end
114
- end
115
- end
116
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'converters'
4
+
5
+ module Spikard
6
+ # Handler wrapper utilities for automatic file metadata conversion
7
+ #
8
+ # Provides ergonomic handler patterns that automatically convert
9
+ # file metadata to UploadFile instances, eliminating boilerplate.
10
+ #
11
+ # @example Basic usage with body only
12
+ # app.post('/upload', &wrap_body_handler do |body|
13
+ # {
14
+ # filename: body[:file].filename,
15
+ # content: body[:file].read
16
+ # }
17
+ # end)
18
+ #
19
+ # @example With all parameters
20
+ # app.post('/upload', &wrap_handler do |params, query, body|
21
+ # {
22
+ # id: params[:id],
23
+ # search: query[:q],
24
+ # file: body[:file].filename
25
+ # }
26
+ # end)
27
+ module HandlerWrapper
28
+ module_function
29
+
30
+ # Wrap a handler that receives only the request body
31
+ #
32
+ # Automatically converts file metadata in the body to UploadFile instances.
33
+ #
34
+ # @yield [body] Handler block that receives converted body
35
+ # @yieldparam body [Hash] Request body with file metadata converted to UploadFile
36
+ # @yieldreturn [Hash, Spikard::Response] Response data or Response object
37
+ # @return [Proc] Wrapped handler proc
38
+ #
39
+ # @example
40
+ # app.post('/upload', &wrap_body_handler do |body|
41
+ # { filename: body[:file].filename }
42
+ # end)
43
+ def wrap_body_handler(&handler)
44
+ raise ArgumentError, 'block required for wrap_body_handler' unless handler
45
+
46
+ # Return a proc that matches the signature expected by Spikard::App
47
+ # The actual handler receives path params, query params, and body from Rust
48
+ lambda do |_params, _query, body|
49
+ converted_body = Converters.convert_handler_body(body)
50
+ handler.call(converted_body)
51
+ end
52
+ end
53
+
54
+ # Wrap a handler that receives path params, query params, and body
55
+ #
56
+ # Automatically converts file metadata in the body to UploadFile instances.
57
+ #
58
+ # @yield [params, query, body] Handler block that receives all request data
59
+ # @yieldparam params [Hash] Path parameters
60
+ # @yieldparam query [Hash] Query parameters
61
+ # @yieldparam body [Hash] Request body with file metadata converted to UploadFile
62
+ # @yieldreturn [Hash, Spikard::Response] Response data or Response object
63
+ # @return [Proc] Wrapped handler proc
64
+ #
65
+ # @example
66
+ # app.post('/users/{id}/upload', &wrap_handler do |params, query, body|
67
+ # {
68
+ # user_id: params[:id],
69
+ # description: query[:desc],
70
+ # file: body[:file].filename
71
+ # }
72
+ # end)
73
+ def wrap_handler(&handler)
74
+ raise ArgumentError, 'block required for wrap_handler' unless handler
75
+
76
+ lambda do |params, query, body|
77
+ converted_body = Converters.convert_handler_body(body)
78
+ handler.call(params, query, converted_body)
79
+ end
80
+ end
81
+
82
+ # Wrap a handler that receives a context hash with all request data
83
+ #
84
+ # Automatically converts file metadata in the body to UploadFile instances.
85
+ # Useful when you want all request data in a single hash.
86
+ #
87
+ # @yield [context] Handler block that receives context hash
88
+ # @yieldparam context [Hash] Request context with:
89
+ # - :params [Hash] Path parameters
90
+ # - :query [Hash] Query parameters
91
+ # - :body [Hash] Request body with file metadata converted to UploadFile
92
+ # @yieldreturn [Hash, Spikard::Response] Response data or Response object
93
+ # @return [Proc] Wrapped handler proc
94
+ #
95
+ # @example
96
+ # app.post('/upload', &wrap_handler_with_context do |ctx|
97
+ # {
98
+ # file: ctx[:body][:file].filename,
99
+ # query_params: ctx[:query]
100
+ # }
101
+ # end)
102
+ def wrap_handler_with_context(&handler)
103
+ raise ArgumentError, 'block required for wrap_handler_with_context' unless handler
104
+
105
+ lambda do |params, query, body|
106
+ converted_body = Converters.convert_handler_body(body)
107
+ context = {
108
+ params: params,
109
+ query: query,
110
+ body: converted_body
111
+ }
112
+ handler.call(context)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,228 @@
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
+ # rubocop:disable Metrics/MethodLength
111
+ def provide(key, value = nil, depends_on: [], singleton: false, cacheable: true, &block)
112
+ key_str = key.to_s
113
+ @dependencies ||= {}
114
+
115
+ # Handle Provide wrapper instances
116
+ 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
+ }
125
+ 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
+ }
134
+ else
135
+ # Value dependency
136
+ raise ArgumentError, 'Either provide a value or a block, not both' if value.nil?
137
+
138
+ @dependencies[key_str] = {
139
+ type: :value,
140
+ value: value,
141
+ singleton: true, # Values are always singleton
142
+ cacheable: true
143
+ }
144
+ end
145
+
146
+ self
147
+ end
148
+ # rubocop:enable Metrics/MethodLength
149
+
150
+ # Get all registered dependencies
151
+ #
152
+ # @return [Hash] Dictionary mapping dependency keys to their definitions
153
+ # @api private
154
+ def dependencies
155
+ @dependencies ||= {}
156
+ @dependencies.dup
157
+ end
158
+ end
159
+
160
+ # Dependency injection handler wrapper
161
+ #
162
+ # Wraps a route handler to inject dependencies based on parameter names.
163
+ # Dependencies are resolved from the DI container and passed as keyword arguments.
164
+ #
165
+ # @api private
166
+ module DIHandlerWrapper
167
+ # Wrap a handler to inject dependencies
168
+ #
169
+ # @param handler [Proc] The original route handler
170
+ # @param dependencies [Hash] Available dependencies from the app
171
+ # @return [Proc] Wrapped handler with DI support
172
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
173
+ def self.wrap_handler(handler, dependencies)
174
+ # Extract parameter names from the handler
175
+ params = handler.parameters.map { |_type, name| name.to_s }
176
+
177
+ # Find which parameters match registered dependencies
178
+ injectable_params = params & dependencies.keys
179
+
180
+ if injectable_params.empty?
181
+ # No DI needed, return original handler
182
+ return handler
183
+ end
184
+
185
+ # Create wrapped handler that injects dependencies
186
+ lambda do |request|
187
+ # Build kwargs with injected dependencies
188
+ kwargs = {}
189
+
190
+ injectable_params.each do |param_name|
191
+ dep_def = dependencies[param_name]
192
+ kwargs[param_name.to_sym] = resolve_dependency(dep_def, request)
193
+ end
194
+
195
+ # Call original handler with injected dependencies
196
+ if handler.arity.zero?
197
+ # Handler takes no arguments (dependencies injected via closure or instance vars)
198
+ handler.call
199
+ elsif injectable_params.length == params.length
200
+ # All parameters are dependencies
201
+ handler.call(**kwargs)
202
+ else
203
+ # Mix of request data and dependencies
204
+ handler.call(request, **kwargs)
205
+ end
206
+ end
207
+ end
208
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
209
+
210
+ # Resolve a dependency definition
211
+ #
212
+ # @param dep_def [Hash] Dependency definition
213
+ # @param request [Hash] Request context (unused for now, future: per-request deps)
214
+ # @return [Object] Resolved dependency value
215
+ # @api private
216
+ def self.resolve_dependency(dep_def, _request)
217
+ case dep_def[:type]
218
+ when :value
219
+ dep_def[:value]
220
+ when :factory
221
+ factory = dep_def[:factory]
222
+ dep_def[:depends_on]
223
+ # TODO: Implement nested dependency resolution when dependencies are provided
224
+ factory.call
225
+ end
226
+ end
227
+ end
228
+ end