spikard 0.2.0 → 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,228 +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
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
@@ -1,109 +1,109 @@
1
- # frozen_string_literal: true
2
-
3
- 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
10
-
11
- def initialize(content: nil, body: nil, status_code: 200, headers: nil, content_type: nil)
12
- @content = content.nil? ? body : content
13
- self.status_code = status_code
14
- self.headers = headers
15
- set_header('content-type', content_type) if content_type
16
- end
17
-
18
- def status
19
- @status_code
20
- end
21
-
22
- def status_code=(value)
23
- @status_code = Integer(value)
24
- rescue ArgumentError, TypeError
25
- raise ArgumentError, 'status_code must be an integer'
26
- end
27
-
28
- def headers=(value)
29
- @headers = normalize_headers(value)
30
- end
31
-
32
- def set_header(name, value)
33
- @headers[name.to_s] = value.to_s
34
- end
35
-
36
- def set_cookie(name, value, **options)
37
- raise ArgumentError, 'cookie name required' if name.nil? || name.empty?
38
-
39
- header_value = ["#{name}=#{value}", *cookie_parts(options)].join('; ')
40
- set_header('set-cookie', header_value)
41
- end
42
-
43
- private
44
-
45
- def cookie_parts(options)
46
- [
47
- options[:max_age] && "Max-Age=#{Integer(options[:max_age])}",
48
- options[:domain] && "Domain=#{options[:domain]}",
49
- "Path=#{options.fetch(:path, '/') || '/'}",
50
- options[:secure] ? 'Secure' : nil,
51
- options[:httponly] ? 'HttpOnly' : nil,
52
- options[:samesite] && "SameSite=#{options[:samesite]}"
53
- ].compact
54
- end
55
-
56
- def normalize_headers(value)
57
- case value
58
- when nil
59
- {}
60
- when Hash
61
- value.each_with_object({}) do |(key, val), acc|
62
- acc[key.to_s] = val.to_s
63
- end
64
- else
65
- raise ArgumentError, 'headers must be a Hash'
66
- end
67
- end
68
- end
69
-
70
- module Testing
71
- # Lightweight wrapper around native response hashes.
72
- class Response
73
- attr_reader :status_code, :headers, :body
74
-
75
- def initialize(payload)
76
- @status_code = payload[:status_code]
77
- @headers = payload[:headers] || {}
78
- @body = payload[:body]
79
- @body_text = payload[:body_text]
80
- end
81
-
82
- def status
83
- @status_code
84
- end
85
-
86
- def body_bytes
87
- @body || ''.b
88
- end
89
-
90
- def body_text
91
- @body_text || @body&.dup&.force_encoding(Encoding::UTF_8)
92
- end
93
-
94
- def text
95
- body_text
96
- end
97
-
98
- def json
99
- return nil if @body.nil? || @body.empty?
100
-
101
- JSON.parse(@body)
102
- end
103
-
104
- def bytes
105
- body_bytes.bytes
106
- end
107
- end
108
- end
109
- end
1
+ # frozen_string_literal: true
2
+
3
+ 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
10
+
11
+ def initialize(content: nil, body: nil, status_code: 200, headers: nil, content_type: nil)
12
+ @content = content.nil? ? body : content
13
+ self.status_code = status_code
14
+ self.headers = headers
15
+ set_header('content-type', content_type) if content_type
16
+ end
17
+
18
+ def status
19
+ @status_code
20
+ end
21
+
22
+ def status_code=(value)
23
+ @status_code = Integer(value)
24
+ rescue ArgumentError, TypeError
25
+ raise ArgumentError, 'status_code must be an integer'
26
+ end
27
+
28
+ def headers=(value)
29
+ @headers = normalize_headers(value)
30
+ end
31
+
32
+ def set_header(name, value)
33
+ @headers[name.to_s] = value.to_s
34
+ end
35
+
36
+ def set_cookie(name, value, **options)
37
+ raise ArgumentError, 'cookie name required' if name.nil? || name.empty?
38
+
39
+ header_value = ["#{name}=#{value}", *cookie_parts(options)].join('; ')
40
+ set_header('set-cookie', header_value)
41
+ end
42
+
43
+ private
44
+
45
+ def cookie_parts(options)
46
+ [
47
+ options[:max_age] && "Max-Age=#{Integer(options[:max_age])}",
48
+ options[:domain] && "Domain=#{options[:domain]}",
49
+ "Path=#{options.fetch(:path, '/') || '/'}",
50
+ options[:secure] ? 'Secure' : nil,
51
+ options[:httponly] ? 'HttpOnly' : nil,
52
+ options[:samesite] && "SameSite=#{options[:samesite]}"
53
+ ].compact
54
+ end
55
+
56
+ def normalize_headers(value)
57
+ case value
58
+ when nil
59
+ {}
60
+ when Hash
61
+ value.each_with_object({}) do |(key, val), acc|
62
+ acc[key.to_s] = val.to_s
63
+ end
64
+ else
65
+ raise ArgumentError, 'headers must be a Hash'
66
+ end
67
+ end
68
+ end
69
+
70
+ module Testing
71
+ # Lightweight wrapper around native response hashes.
72
+ class Response
73
+ attr_reader :status_code, :headers, :body
74
+
75
+ def initialize(payload)
76
+ @status_code = payload[:status_code]
77
+ @headers = payload[:headers] || {}
78
+ @body = payload[:body]
79
+ @body_text = payload[:body_text]
80
+ end
81
+
82
+ def status
83
+ @status_code
84
+ end
85
+
86
+ def body_bytes
87
+ @body || ''.b
88
+ end
89
+
90
+ def body_text
91
+ @body_text || @body&.dup&.force_encoding(Encoding::UTF_8)
92
+ end
93
+
94
+ def text
95
+ body_text
96
+ end
97
+
98
+ def json
99
+ return nil if @body.nil? || @body.empty?
100
+
101
+ JSON.parse(@body)
102
+ end
103
+
104
+ def bytes
105
+ body_bytes.bytes
106
+ end
107
+ end
108
+ end
109
+ end