spikard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spikard
4
+ # Compression configuration for response compression middleware.
5
+ #
6
+ # Spikard supports gzip and brotli compression for responses.
7
+ # Compression is applied based on Accept-Encoding headers.
8
+ #
9
+ # @example
10
+ # compression = CompressionConfig.new(
11
+ # gzip: true,
12
+ # brotli: true,
13
+ # min_size: 1024,
14
+ # quality: 6
15
+ # )
16
+ class CompressionConfig
17
+ attr_accessor :gzip, :brotli, :min_size, :quality
18
+
19
+ # @param gzip [Boolean] Enable gzip compression (default: true)
20
+ # @param brotli [Boolean] Enable brotli compression (default: true)
21
+ # @param min_size [Integer] Minimum response size in bytes to compress (default: 1024)
22
+ # @param quality [Integer] Compression quality level (0-11 for brotli, 0-9 for gzip, default: 6)
23
+ def initialize(gzip: true, brotli: true, min_size: 1024, quality: 6)
24
+ @gzip = gzip
25
+ @brotli = brotli
26
+ @min_size = min_size
27
+ @quality = quality
28
+ end
29
+ end
30
+
31
+ # Rate limiting configuration using Generic Cell Rate Algorithm (GCRA).
32
+ #
33
+ # By default, rate limits are applied per IP address.
34
+ #
35
+ # @example
36
+ # rate_limit = RateLimitConfig.new(
37
+ # per_second: 100,
38
+ # burst: 200,
39
+ # ip_based: true
40
+ # )
41
+ class RateLimitConfig
42
+ attr_accessor :per_second, :burst, :ip_based
43
+
44
+ # @param per_second [Integer] Maximum requests per second
45
+ # @param burst [Integer] Burst allowance - allows temporary spikes
46
+ # @param ip_based [Boolean] Apply rate limits per IP address (default: true)
47
+ def initialize(per_second:, burst:, ip_based: true)
48
+ @per_second = per_second
49
+ @burst = burst
50
+ @ip_based = ip_based
51
+ end
52
+ end
53
+
54
+ # JWT authentication configuration.
55
+ #
56
+ # Validates JWT tokens using the specified secret and algorithm.
57
+ # Tokens are expected in the Authorization header as "Bearer <token>".
58
+ #
59
+ # Supported algorithms:
60
+ # - HS256, HS384, HS512 (HMAC with SHA)
61
+ # - RS256, RS384, RS512 (RSA signatures)
62
+ # - ES256, ES384, ES512 (ECDSA signatures)
63
+ # - PS256, PS384, PS512 (RSA-PSS signatures)
64
+ #
65
+ # @example
66
+ # jwt = JwtConfig.new(
67
+ # secret: 'your-secret-key',
68
+ # algorithm: 'HS256',
69
+ # audience: ['api.example.com'],
70
+ # issuer: 'auth.example.com',
71
+ # leeway: 30
72
+ # )
73
+ class JwtConfig
74
+ attr_accessor :secret, :algorithm, :audience, :issuer, :leeway
75
+
76
+ # @param secret [String] Secret key for JWT validation
77
+ # @param algorithm [String] JWT algorithm (default: "HS256")
78
+ # @param audience [Array<String>, nil] Expected audience claim(s)
79
+ # @param issuer [String, nil] Expected issuer claim
80
+ # @param leeway [Integer] Time leeway in seconds for exp/nbf/iat claims (default: 0)
81
+ def initialize(secret:, algorithm: 'HS256', audience: nil, issuer: nil, leeway: 0)
82
+ @secret = secret
83
+ @algorithm = algorithm
84
+ @audience = audience
85
+ @issuer = issuer
86
+ @leeway = leeway
87
+ end
88
+ end
89
+
90
+ # API key authentication configuration.
91
+ #
92
+ # Validates API keys from request headers. Keys are matched exactly.
93
+ #
94
+ # @example
95
+ # api_key = ApiKeyConfig.new(
96
+ # keys: ['key-1', 'key-2', 'key-3'],
97
+ # header_name: 'X-API-Key'
98
+ # )
99
+ class ApiKeyConfig
100
+ attr_accessor :keys, :header_name
101
+
102
+ # @param keys [Array<String>] List of valid API keys
103
+ # @param header_name [String] HTTP header name to check for API key (default: "X-API-Key")
104
+ def initialize(keys:, header_name: 'X-API-Key')
105
+ @keys = keys
106
+ @header_name = header_name
107
+ end
108
+ end
109
+
110
+ # Static file serving configuration.
111
+ #
112
+ # Serves files from a directory at a given route prefix.
113
+ # Multiple static file configurations can be registered.
114
+ #
115
+ # @example
116
+ # static = StaticFilesConfig.new(
117
+ # directory: './public',
118
+ # route_prefix: '/static',
119
+ # index_file: true,
120
+ # cache_control: 'public, max-age=3600'
121
+ # )
122
+ class StaticFilesConfig
123
+ attr_accessor :directory, :route_prefix, :index_file, :cache_control
124
+
125
+ # @param directory [String] Directory path containing static files
126
+ # @param route_prefix [String] URL prefix for serving static files (e.g., "/static")
127
+ # @param index_file [Boolean] Serve index.html for directory requests (default: true)
128
+ # @param cache_control [String, nil] Optional Cache-Control header value (e.g., "public, max-age=3600")
129
+ def initialize(directory:, route_prefix:, index_file: true, cache_control: nil)
130
+ @directory = directory
131
+ @route_prefix = route_prefix
132
+ @index_file = index_file
133
+ @cache_control = cache_control
134
+ end
135
+ end
136
+
137
+ # Contact information for OpenAPI documentation.
138
+ #
139
+ # @example
140
+ # contact = ContactInfo.new(
141
+ # name: 'API Team',
142
+ # email: 'api@example.com',
143
+ # url: 'https://example.com'
144
+ # )
145
+ class ContactInfo
146
+ attr_accessor :name, :email, :url
147
+
148
+ # @param name [String, nil] Name of the contact person/organization
149
+ # @param email [String, nil] Email address for contact
150
+ # @param url [String, nil] URL for contact information
151
+ def initialize(name: nil, email: nil, url: nil)
152
+ @name = name
153
+ @email = email
154
+ @url = url
155
+ end
156
+ end
157
+
158
+ # License information for OpenAPI documentation.
159
+ #
160
+ # @example
161
+ # license = LicenseInfo.new(
162
+ # name: 'MIT',
163
+ # url: 'https://opensource.org/licenses/MIT'
164
+ # )
165
+ class LicenseInfo
166
+ attr_accessor :name, :url
167
+
168
+ # @param name [String] License name (e.g., "MIT", "Apache 2.0")
169
+ # @param url [String, nil] URL to the full license text
170
+ def initialize(name:, url: nil)
171
+ @name = name
172
+ @url = url
173
+ end
174
+ end
175
+
176
+ # Server information for OpenAPI documentation.
177
+ #
178
+ # Multiple servers can be specified for different environments.
179
+ #
180
+ # @example
181
+ # server = ServerInfo.new(
182
+ # url: 'https://api.example.com',
183
+ # description: 'Production'
184
+ # )
185
+ class ServerInfo
186
+ attr_accessor :url, :description
187
+
188
+ # @param url [String] Server URL (e.g., "https://api.example.com")
189
+ # @param description [String, nil] Description of the server (e.g., "Production", "Staging")
190
+ def initialize(url:, description: nil)
191
+ @url = url
192
+ @description = description
193
+ end
194
+ end
195
+
196
+ # Security scheme configuration for OpenAPI documentation.
197
+ #
198
+ # Supports HTTP (Bearer/JWT) and API Key authentication schemes.
199
+ #
200
+ # @example HTTP Bearer
201
+ # scheme = SecuritySchemeInfo.new(
202
+ # type: 'http',
203
+ # scheme: 'bearer',
204
+ # bearer_format: 'JWT'
205
+ # )
206
+ #
207
+ # @example API Key
208
+ # scheme = SecuritySchemeInfo.new(
209
+ # type: 'apiKey',
210
+ # location: 'header',
211
+ # name: 'X-API-Key'
212
+ # )
213
+ class SecuritySchemeInfo
214
+ attr_accessor :type, :scheme, :bearer_format, :location, :name
215
+
216
+ # @param type [String] Security scheme type ("http" or "apiKey")
217
+ # @param scheme [String, nil] HTTP scheme (e.g., "bearer", "basic") - for type="http"
218
+ # @param bearer_format [String, nil] Format hint for Bearer tokens (e.g., "JWT") - for type="http"
219
+ # @param location [String, nil] Where to look for the API key ("header", "query", or "cookie") - for type="apiKey"
220
+ # @param name [String, nil] Parameter name (e.g., "X-API-Key") - for type="apiKey"
221
+ def initialize(type:, scheme: nil, bearer_format: nil, location: nil, name: nil)
222
+ @type = type
223
+ @scheme = scheme
224
+ @bearer_format = bearer_format
225
+ @location = location
226
+ @name = name
227
+
228
+ validate!
229
+ end
230
+
231
+ private
232
+
233
+ def validate!
234
+ case @type
235
+ when 'http'
236
+ raise ArgumentError, 'scheme is required for type="http"' if @scheme.nil?
237
+ when 'apiKey'
238
+ raise ArgumentError, 'location and name are required for type="apiKey"' if @location.nil? || @name.nil?
239
+ else
240
+ raise ArgumentError, "type must be 'http' or 'apiKey', got: #{@type.inspect}"
241
+ end
242
+ end
243
+ end
244
+
245
+ # OpenAPI 3.1.0 documentation configuration.
246
+ #
247
+ # Spikard can automatically generate OpenAPI documentation from your routes.
248
+ # When enabled, it serves:
249
+ # - Swagger UI at /docs (customizable)
250
+ # - Redoc at /redoc (customizable)
251
+ # - OpenAPI JSON spec at /openapi.json (customizable)
252
+ #
253
+ # Security schemes are auto-detected from middleware configuration.
254
+ # Schemas are generated from your route type hints and validation.
255
+ #
256
+ # @example
257
+ # openapi = OpenApiConfig.new(
258
+ # enabled: true,
259
+ # title: 'My API',
260
+ # version: '1.0.0',
261
+ # description: 'A great API built with Spikard',
262
+ # contact: ContactInfo.new(
263
+ # name: 'API Team',
264
+ # email: 'api@example.com',
265
+ # url: 'https://example.com'
266
+ # ),
267
+ # license: LicenseInfo.new(
268
+ # name: 'MIT',
269
+ # url: 'https://opensource.org/licenses/MIT'
270
+ # ),
271
+ # servers: [
272
+ # ServerInfo.new(url: 'https://api.example.com', description: 'Production'),
273
+ # ServerInfo.new(url: 'http://localhost:8000', description: 'Development')
274
+ # ]
275
+ # )
276
+ class OpenApiConfig
277
+ attr_accessor :enabled, :title, :version, :description,
278
+ :swagger_ui_path, :redoc_path, :openapi_json_path,
279
+ :contact, :license, :servers, :security_schemes
280
+
281
+ # @param enabled [Boolean] Enable OpenAPI generation (default: false for zero overhead)
282
+ # @param title [String] API title (default: "API")
283
+ # @param version [String] API version (default: "1.0.0")
284
+ # @param description [String, nil] API description (supports Markdown)
285
+ # @param swagger_ui_path [String] Path to serve Swagger UI (default: "/docs")
286
+ # @param redoc_path [String] Path to serve Redoc (default: "/redoc")
287
+ # @param openapi_json_path [String] Path to serve OpenAPI JSON spec (default: "/openapi.json")
288
+ # @param contact [ContactInfo, nil] Contact information for the API
289
+ # @param license [LicenseInfo, nil] License information for the API
290
+ # @param servers [Array<ServerInfo>] List of server URLs for different environments (default: [])
291
+ # @param security_schemes [Hash<String, SecuritySchemeInfo>] Custom security schemes (auto-detected if not provided)
292
+ def initialize(
293
+ enabled: false,
294
+ title: 'API',
295
+ version: '1.0.0',
296
+ description: nil,
297
+ swagger_ui_path: '/docs',
298
+ redoc_path: '/redoc',
299
+ openapi_json_path: '/openapi.json',
300
+ contact: nil,
301
+ license: nil,
302
+ servers: [],
303
+ security_schemes: {}
304
+ )
305
+ @enabled = enabled
306
+ @title = title
307
+ @version = version
308
+ @description = description
309
+ @swagger_ui_path = swagger_ui_path
310
+ @redoc_path = redoc_path
311
+ @openapi_json_path = openapi_json_path
312
+ @contact = contact
313
+ @license = license
314
+ @servers = servers
315
+ @security_schemes = security_schemes
316
+ end
317
+ end
318
+
319
+ # Complete server configuration for Spikard.
320
+ #
321
+ # This is the main configuration object that controls all aspects of the server
322
+ # including network settings, middleware, authentication, and more.
323
+ #
324
+ # @example
325
+ # config = ServerConfig.new(
326
+ # host: '0.0.0.0',
327
+ # port: 8080,
328
+ # workers: 4,
329
+ # compression: CompressionConfig.new(quality: 9),
330
+ # rate_limit: RateLimitConfig.new(per_second: 100, burst: 200),
331
+ # static_files: [
332
+ # StaticFilesConfig.new(
333
+ # directory: './public',
334
+ # route_prefix: '/static'
335
+ # )
336
+ # ],
337
+ # openapi: OpenApiConfig.new(
338
+ # enabled: true,
339
+ # title: 'My API',
340
+ # version: '1.0.0'
341
+ # )
342
+ # )
343
+ class ServerConfig
344
+ attr_accessor :host, :port, :workers,
345
+ :enable_request_id, :max_body_size, :request_timeout,
346
+ :compression, :rate_limit, :jwt_auth, :api_key_auth,
347
+ :static_files, :graceful_shutdown, :shutdown_timeout,
348
+ :openapi
349
+
350
+ # @param host [String] Host address to bind to (default: "127.0.0.1")
351
+ # @param port [Integer] Port number to listen on (default: 8000, range: 1-65535)
352
+ # @param workers [Integer] Number of worker processes (default: 1)
353
+ # @param enable_request_id [Boolean] Add X-Request-ID header to responses (default: true)
354
+ # @param max_body_size [Integer, nil] Maximum request body size in bytes (default: 10MB, nil for unlimited)
355
+ # @param request_timeout [Integer, nil] Request timeout in seconds (default: 30, nil for no timeout)
356
+ # @param compression [CompressionConfig, nil] Response compression configuration (default: enabled with defaults)
357
+ # @param rate_limit [RateLimitConfig, nil] Rate limiting configuration (default: nil/disabled)
358
+ # @param jwt_auth [JwtConfig, nil] JWT authentication configuration (default: nil/disabled)
359
+ # @param api_key_auth [ApiKeyConfig, nil] API key authentication configuration (default: nil/disabled)
360
+ # @param static_files [Array<StaticFilesConfig>] List of static file serving configurations (default: [])
361
+ # @param graceful_shutdown [Boolean] Enable graceful shutdown (default: true)
362
+ # @param shutdown_timeout [Integer] Graceful shutdown timeout in seconds (default: 30)
363
+ # @param openapi [OpenApiConfig, nil] OpenAPI configuration (default: nil/disabled)
364
+ def initialize(
365
+ host: '127.0.0.1',
366
+ port: 8000,
367
+ workers: 1,
368
+ enable_request_id: true,
369
+ max_body_size: 10 * 1024 * 1024, # 10MB
370
+ request_timeout: 30,
371
+ compression: CompressionConfig.new,
372
+ rate_limit: nil,
373
+ jwt_auth: nil,
374
+ api_key_auth: nil,
375
+ static_files: [],
376
+ graceful_shutdown: true,
377
+ shutdown_timeout: 30,
378
+ openapi: nil
379
+ )
380
+ @host = host
381
+ @port = port
382
+ @workers = workers
383
+ @enable_request_id = enable_request_id
384
+ @max_body_size = max_body_size
385
+ @request_timeout = request_timeout
386
+ @compression = compression
387
+ @rate_limit = rate_limit
388
+ @jwt_auth = jwt_auth
389
+ @api_key_auth = api_key_auth
390
+ @static_files = static_files
391
+ @graceful_shutdown = graceful_shutdown
392
+ @shutdown_timeout = shutdown_timeout
393
+ @openapi = openapi
394
+ end
395
+ end
396
+ end
@@ -0,0 +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
@@ -0,0 +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
@@ -0,0 +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