aris 1.3.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +342 -0
  4. data/lib/aris/adapters/base.rb +25 -0
  5. data/lib/aris/adapters/joys_integration.rb +94 -0
  6. data/lib/aris/adapters/mock/adapter.rb +141 -0
  7. data/lib/aris/adapters/mock/request.rb +81 -0
  8. data/lib/aris/adapters/mock/response.rb +17 -0
  9. data/lib/aris/adapters/rack/adapter.rb +117 -0
  10. data/lib/aris/adapters/rack/request.rb +66 -0
  11. data/lib/aris/adapters/rack/response.rb +16 -0
  12. data/lib/aris/core.rb +931 -0
  13. data/lib/aris/discovery.rb +312 -0
  14. data/lib/aris/locale_injector.rb +39 -0
  15. data/lib/aris/pipeline_runner.rb +100 -0
  16. data/lib/aris/plugins/api_key_auth.rb +61 -0
  17. data/lib/aris/plugins/basic_auth.rb +68 -0
  18. data/lib/aris/plugins/bearer_auth.rb +64 -0
  19. data/lib/aris/plugins/cache.rb +120 -0
  20. data/lib/aris/plugins/compression.rb +96 -0
  21. data/lib/aris/plugins/cookies.rb +46 -0
  22. data/lib/aris/plugins/cors.rb +81 -0
  23. data/lib/aris/plugins/csrf.rb +48 -0
  24. data/lib/aris/plugins/etag.rb +90 -0
  25. data/lib/aris/plugins/flash.rb +124 -0
  26. data/lib/aris/plugins/form_parser.rb +46 -0
  27. data/lib/aris/plugins/health_check.rb +62 -0
  28. data/lib/aris/plugins/json.rb +32 -0
  29. data/lib/aris/plugins/multipart.rb +160 -0
  30. data/lib/aris/plugins/rate_limiter.rb +60 -0
  31. data/lib/aris/plugins/request_id.rb +38 -0
  32. data/lib/aris/plugins/request_logger.rb +43 -0
  33. data/lib/aris/plugins/security_headers.rb +99 -0
  34. data/lib/aris/plugins/session.rb +175 -0
  35. data/lib/aris/plugins.rb +23 -0
  36. data/lib/aris/response_helpers.rb +156 -0
  37. data/lib/aris/route_helpers.rb +141 -0
  38. data/lib/aris/utils/redirects.rb +44 -0
  39. data/lib/aris/utils/sitemap.rb +84 -0
  40. data/lib/aris/version.rb +3 -0
  41. data/lib/aris.rb +35 -0
  42. metadata +151 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 939f838547c98d96452cf200c2b5ae4d9cbdfaf17f6811415f2f341e7cf96a32
4
+ data.tar.gz: c7c25cd371a1413068d0b490b7d92fa29809c077fde4c04f75a8619427de600f
5
+ SHA512:
6
+ metadata.gz: 4f4b0181b8df3cf3f7b421cbfef4bf9082324e833f31c688d638c41808845b81842448243da9e91279d0781f690edb7b76fde2c6859d060df58c4e3bf906e9fb
7
+ data.tar.gz: 756840ba2f35be79777e3aeeb01be6a4ca560119d85d5e5ba11a725cc7f87ab8e2afa73a0fbd22c5ac97f4dcccfde31bf1c1ff621cddeea62c4864c3df15bdc9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Steven Garcia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,342 @@
1
+ <img width="1696" height="704" alt="aris" src="https://github.com/user-attachments/assets/3f0a67d1-c4c0-4357-b0e7-8d8ad62563ba" />
2
+
3
+ # Aris
4
+
5
+ **A fast, framework-agnostic router for Ruby.**
6
+
7
+ Aris treats routes as plain data structures instead of code. This design choice makes routing predictable, testable, and roughly 3× faster than comparable frameworks. It works anywhere—Rack apps, CLI tools, background jobs, or custom servers.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ gem 'aris'
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ Routes are just hashes. Define them once at boot, and Aris compiles them into an optimized lookup structure.
18
+
19
+ ```ruby
20
+ Aris.routes({
21
+ "api.example.com": {
22
+ "/users/:id": {
23
+ get: { to: UserHandler, as: :user }
24
+ }
25
+ }
26
+ })
27
+ ```
28
+
29
+ Match incoming requests:
30
+
31
+ ```ruby
32
+ result = Aris::Router.match(
33
+ domain: "api.example.com",
34
+ method: :get,
35
+ path: "/users/123"
36
+ )
37
+
38
+ result[:handler] # => UserHandler
39
+ result[:params] # => { id: "123" }
40
+ ```
41
+
42
+ Generate paths from named routes:
43
+
44
+ ```ruby
45
+ Aris.path("api.example.com", :user, id: 123)
46
+ # => "/users/123"
47
+ ```
48
+
49
+ That's the core. Everything else builds on these three concepts: define, match, generate.
50
+
51
+ ---
52
+
53
+ ## Core Features
54
+
55
+ ### Multiple HTTP Methods
56
+
57
+ ```ruby
58
+ Aris.routes({
59
+ "example.com": {
60
+ "/posts/:id": {
61
+ get: { to: PostShowHandler },
62
+ put: { to: PostUpdateHandler },
63
+ delete: { to: PostDeleteHandler }
64
+ }
65
+ }
66
+ })
67
+ ```
68
+
69
+ ### Wildcards for Catch-All Routes
70
+
71
+ ```ruby
72
+ Aris.routes({
73
+ "example.com": {
74
+ "/files/*path": { get: { to: FileHandler } }
75
+ }
76
+ })
77
+
78
+ result = Aris::Router.match(
79
+ domain: "example.com",
80
+ method: :get,
81
+ path: "/files/docs/2024/report.pdf"
82
+ )
83
+ result[:params] # => { path: "docs/2024/report.pdf" }
84
+ ```
85
+
86
+ ### Parameter Constraints
87
+
88
+ Validate parameters at the routing level to fail fast on invalid input.
89
+
90
+ ```ruby
91
+ Aris.routes({
92
+ "example.com": {
93
+ "/users/:id": {
94
+ get: {
95
+ to: UserHandler,
96
+ constraints: { id: /\A\d{1,8}\z/ }
97
+ }
98
+ }
99
+ }
100
+ })
101
+ ```
102
+
103
+ ### Multi-Domain Routing
104
+
105
+ Define different routing trees per domain, with wildcard fallback support.
106
+
107
+ ```ruby
108
+ Aris.routes({
109
+ "example.com": {
110
+ "/": { get: { to: HomeHandler } }
111
+ },
112
+ "admin.example.com": {
113
+ "/": { get: { to: AdminDashboardHandler } }
114
+ },
115
+ "*": {
116
+ "/health": { get: { to: HealthHandler } }
117
+ }
118
+ })
119
+ ```
120
+
121
+ ### Composable Plugins
122
+
123
+ Plugins execute between routing and handler dispatch. They're just callables that can inspect, modify, or halt the request.
124
+
125
+ ```ruby
126
+ class Auth
127
+ def self.call(request, response)
128
+ unless authorized?(request)
129
+ response.status = 401
130
+ response.body = ['Unauthorized']
131
+ return response # Halts processing
132
+ end
133
+ nil # Continue to next plugin or handler
134
+ end
135
+ end
136
+
137
+ Aris.routes({
138
+ "api.example.com": {
139
+ use: [CorsHeaders, Auth], # Applied to all routes
140
+ "/users": { get: { to: UsersHandler } }
141
+ }
142
+ })
143
+ ```
144
+
145
+ Plugins inherit down the routing tree and can be overridden at any level.
146
+
147
+ ---
148
+
149
+ ### File-Based Route Discovery
150
+
151
+ Define routes by creating files instead of writing hash definitions. The directory structure maps directly to your routes.
152
+
153
+ ```ruby
154
+ # Directory structure:
155
+ # app/routes/
156
+ # example.com/
157
+ # users/
158
+ # get.rb # GET /users
159
+ # _id/
160
+ # get.rb # GET /users/:id
161
+ # _/
162
+ # health/
163
+ # get.rb # GET /health on any domain
164
+
165
+ # app/routes/example.com/users/_id/get.rb
166
+ class Handler
167
+ def self.call(request, params)
168
+ { id: params[:id], name: "User #{params[:id]}" }
169
+ end
170
+ end
171
+
172
+ # Boot configuration
173
+ Aris.discover_and_define('app/routes')
174
+ ```
175
+
176
+ Convention: `domain/path/segments/_param/method.rb`. Use `_` for wildcard domains and parameter names. Each file defines a `Handler` class with a `.call` method.
177
+
178
+
179
+ ---
180
+
181
+ ## Use Cases
182
+
183
+ ### Rack Applications
184
+
185
+ ```ruby
186
+ # config.ru
187
+ Aris.routes({
188
+ "example.com": {
189
+ "/": { get: { to: HomeHandler } }
190
+ }
191
+ })
192
+
193
+ run Aris::Adapters::RackApp.new
194
+ ```
195
+
196
+ ### CLI Tools
197
+
198
+ ```ruby
199
+ # Route commands to handlers
200
+ result = Aris::Router.match(
201
+ domain: "cli.internal",
202
+ method: :get,
203
+ path: "/users/#{ARGV[0]}/report"
204
+ )
205
+
206
+ result[:handler].call(result[:params]) if result
207
+ ```
208
+
209
+ ### Background Jobs
210
+
211
+ ```ruby
212
+ class WebhookRouter
213
+ def self.route(event_type, payload)
214
+ result = Aris::Router.match(
215
+ domain: "webhooks.internal",
216
+ method: :post,
217
+ path: "/events/#{event_type}"
218
+ )
219
+
220
+ result[:handler].call(payload) if result
221
+ end
222
+ end
223
+ ```
224
+
225
+ ### Custom Servers
226
+
227
+ ```ruby
228
+ class ServerAdapter
229
+ def handle(native_request)
230
+ result = Aris::Router.match(
231
+ domain: native_request.host,
232
+ method: native_request.method,
233
+ path: native_request.path
234
+ )
235
+
236
+ return [404, {}, ['Not Found']] unless result
237
+
238
+ result[:handler].call(native_request, result[:params])
239
+ end
240
+ end
241
+ ```
242
+
243
+ ---
244
+
245
+ ## Error Handling
246
+
247
+ Define custom handlers for 404s and 500s once, and they'll be used everywhere.
248
+
249
+ ```ruby
250
+ Aris.default(
251
+ not_found: ->(req, params) {
252
+ [404, {}, ['{"error": "Not found"}']]
253
+ },
254
+ error: ->(req, exception) {
255
+ ErrorTracker.log(exception)
256
+ [500, {}, ['{"error": "Internal error"}']]
257
+ }
258
+ )
259
+ ```
260
+
261
+ Trigger these handlers from your code:
262
+
263
+ ```ruby
264
+ class UserHandler
265
+ def self.call(request, params)
266
+ user = User.find(params[:id])
267
+ return Aris.not_found(request) unless user
268
+
269
+ user.to_json
270
+ end
271
+ end
272
+ ```
273
+
274
+
275
+
276
+ ---
277
+
278
+ ## Performance
279
+
280
+ Aris compiles routes into a Trie structure at boot time, enabling constant-time lookups regardless of route count. Benchmarks against Roda show 2.5-3.8× faster routing across all scenarios.
281
+
282
+ ```
283
+ Root path: 3.1× faster (570ns vs 1.75μs)
284
+ Single parameter: 2.8× faster (998ns vs 2.78μs)
285
+ Two parameters: 3.0× faster (1.31μs vs 3.89μs)
286
+ ```
287
+
288
+ Route matching is O(k) where k is path depth, not route count. Adding 1,000 routes has zero impact on lookup speed—only compilation time increases (3ms for 1,000 routes).
289
+
290
+ **Why is it fast?**
291
+
292
+ Routes are data structures, not code. Matching a request is a tree traversal with no method dispatch, no block evaluation, and no runtime metaprogramming. The implementation caches aggressively and minimizes object allocation in the hot path.
293
+
294
+ ---
295
+
296
+ ## Documentation
297
+
298
+ **[Full Usage Guide](docs/USAGE.md)** - Complete API reference with detailed examples
299
+ **[Adapters Guide](docs/ADAPTERS.md)** - Build on top of aris agnostic interface
300
+ **[Architecture](docs/ARCHITECTURE.md)** - Learn all about the design decisions behind Aris
301
+ **[Plugin Development](docs/PLUGIN_DEVELOPMENT.md)** - How to build custom middleware
302
+ **[Performance Details](docs/PERFORMANCE.md)** - Benchmarks, profiling, and optimization
303
+
304
+ ---
305
+
306
+ ## FAQ
307
+
308
+ **Can I use this with Rails or Sinatra?**
309
+ Yes, but it's better suited for new projects or specific use cases like API-only apps, CLI routing, or background job dispatch. For existing apps, Aris works well alongside framework routing rather than replacing it.
310
+
311
+ **How does this compare to Rack middleware?**
312
+ Aris's plugins run after route matching, so they have access to route parameters and metadata. Rack middleware runs before routing and only sees raw HTTP data. Use both together—Rack for HTTP concerns, Aris plugins for application logic.
313
+
314
+ **What if I need to add routes at runtime?**
315
+ Calling `Aris.routes` performs a full recompilation (milliseconds for thousands of routes) and isn't thread-safe. Most apps define routes once at boot. For truly dynamic routing, use parameterized routes with dynamic handler logic instead.
316
+
317
+ **Does it support regex patterns in routes?**
318
+ No, because that would break the Trie optimization. Use wildcard parameters (`*path`) for globbing and constraints for validation. This keeps routing fast while still enforcing requirements.
319
+
320
+ **How do I handle API versioning?**
321
+ Use domain-based (`v1.api.example.com`) or path-based (`/api/v1/users`) versioning. Both work identically—domain-based gives cleaner separation, path-based keeps everything on one domain.
322
+
323
+ ---
324
+
325
+ ## Contributing
326
+
327
+ Pull requests welcome. The codebase prioritizes simplicity and performance—every feature should justify its complexity and impact on routing speed.
328
+
329
+ Run tests: `ruby test/run_all_tests.rb`
330
+ Run benchmarks: `ruby test/bench/vs_roda.rb`
331
+
332
+ ---
333
+
334
+ ## License
335
+
336
+ MIT
337
+
338
+ ---
339
+
340
+ ## Acknowledgments
341
+
342
+ Inspired by the pragmatic design of Roda, the performance focus of Aaron Patterson's work, and the elegance of Rack. Thanks to the Ruby community for constantly pushing what's possible.
@@ -0,0 +1,25 @@
1
+ # lib/aris/adapters/base.rb
2
+ module Aris
3
+ module Adapters
4
+ class Base
5
+ def handle_sitemap(request)
6
+ return nil unless defined?(Aris::Utils::Sitemap) && request.path == '/sitemap.xml'
7
+
8
+ xml = Aris::Utils::Sitemap.generate(
9
+ base_url: "#{request.scheme}://#{request.host}",
10
+ domain: request.host
11
+ )
12
+ [200, {'content-type' => 'application/xml'}, [xml]]
13
+ end
14
+
15
+ def handle_redirect(request)
16
+ return nil unless defined?(Aris::Utils::Redirects)
17
+
18
+ redirect = Aris::Utils::Redirects.find(request.path)
19
+ return nil unless redirect
20
+
21
+ [redirect[:status], {'Location' => redirect[:to]}, []]
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,94 @@
1
+ require_relative 'base'
2
+
3
+ class JoysPlugin
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+ def call(env)
8
+ request = Rack::Request.new(env)
9
+ Thread.current[:joys_request] = request
10
+ Thread.current[:joys_params] = {}
11
+
12
+ begin
13
+ @app.call(env)
14
+ ensure
15
+ # Comprehensive cleanup
16
+ Thread.current[:joys_request] = nil
17
+ Thread.current[:joys_params] = nil
18
+ Thread.current[:joys_locals] = nil
19
+ end
20
+ end
21
+ end
22
+
23
+ module Joys
24
+ module Adapters
25
+ class Aris < ::Aris::Adapters::Base
26
+ def integrate!
27
+ inject_helpers!
28
+ [::Aris::Adapters::Mock::Response, ::Aris::Adapters::Rack::Response].each do |klass|
29
+ klass.include(Renderer)
30
+ end
31
+ end
32
+
33
+ def inject_helpers!
34
+ Joys::Render::Helpers.module_eval do
35
+ def request; Thread.current[:joys_request]; end
36
+ def params; Thread.current[:joys_params]; end
37
+ def _(content); raw(content); end
38
+ def path(route_name, **params)
39
+ ::Aris.path(route_name, **params) # Call on ::Aris module, not the adapter
40
+ end
41
+ def make(name, *args, **kwargs, &block)
42
+ Joys.define :comp, name, *args, **kwargs, &block
43
+ end
44
+ def load_css_file(*names)
45
+ names.each do |name|
46
+ file_path = File.join(Joys::Config.css_parts, "#{name}.css")
47
+ if File.exist?(file_path)
48
+ raw File.read(file_path)
49
+ else
50
+ raise "CSS file not found: #{file_path}"
51
+ end
52
+ end
53
+ nil
54
+ end
55
+ end
56
+ end
57
+
58
+
59
+ module Renderer
60
+ def render(path, **locals)
61
+ Thread.current[:joys_params] = locals[:params] || {}
62
+ file = File.join(Joys::Config.pages, "#{path}.rb")
63
+ raise "Template not found: #{file}" unless File.exist?(file)
64
+
65
+ renderer = Object.new
66
+ renderer.extend(Joys::Render::Helpers)
67
+ renderer.extend(Joys::Tags)
68
+
69
+ # Set the page name for style compilation
70
+ page_name = "page_#{path.gsub('/', '_')}"
71
+
72
+ # Initialize ALL Joys state on the renderer instance
73
+ renderer.instance_variable_set(:@bf, String.new(capacity: 16384))
74
+ renderer.instance_variable_set(:@slots, {})
75
+ renderer.instance_variable_set(:@styles, [])
76
+ renderer.instance_variable_set(:@style_base_css, [])
77
+ renderer.instance_variable_set(:@style_media_queries, {})
78
+ renderer.instance_variable_set(:@used_components, Set.new)
79
+ renderer.instance_variable_set(:@current_page, page_name) # ADD THIS!
80
+
81
+ # Inject locals as methods
82
+ locals.each { |k, v| renderer.define_singleton_method(k) { v } }
83
+
84
+ # Evaluate the page file
85
+ renderer.instance_eval(File.read(file), file)
86
+
87
+ # Set the HTML on the Aris response
88
+ self.html(renderer.instance_variable_get(:@bf))
89
+ self
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,141 @@
1
+ # lib/aris/adapters/mock/adapter.rb
2
+ require_relative '../../core'
3
+ require_relative '../../pipeline_runner'
4
+ require_relative 'request'
5
+ require_relative 'response'
6
+ require 'json'
7
+
8
+ module Aris
9
+ module Adapters
10
+ module Mock
11
+ class Adapter
12
+ def call(method:, path:, domain:, headers: {}, body: '', query: '')
13
+ # Parse cookies from headers before creating request
14
+ cookies = parse_cookies(headers)
15
+
16
+ request = Request.new(
17
+ method: method,
18
+ path: path,
19
+ domain: domain,
20
+ headers: headers,
21
+ body: body,
22
+ query: query,
23
+ cookies: cookies # Add cookies
24
+ )
25
+
26
+ request_domain = request.host
27
+ Thread.current[:aris_current_domain] = request_domain
28
+
29
+ begin
30
+ redirect_response, normalized_path = Aris.handle_trailing_slash(path)
31
+ if redirect_response
32
+ return {
33
+ status: redirect_response[0],
34
+ headers: redirect_response[1],
35
+ body: redirect_response[2]
36
+ }
37
+ end
38
+ domain_config = Aris::Router.domain_config(domain)
39
+ if domain_config && domain_config[:locales] && domain_config[:locales].any? && domain_config[:root_locale_redirect] != false
40
+ if path == '/' || path.empty?
41
+ default_locale = domain_config[:default_locale] || domain_config[:locales].first
42
+ return { status: 302, headers: {'Location' => "/#{default_locale}/"}, body: [] }
43
+ end
44
+ end
45
+
46
+ if defined?(Aris::Utils::Sitemap) && path == '/sitemap.xml'
47
+ xml = Aris::Utils::Sitemap.generate(base_url: "https://#{domain}", domain: domain)
48
+ return { status: 200, headers: {'content-type' => 'application/xml'}, body: [xml] }
49
+ end
50
+
51
+ # Check redirects
52
+ if defined?(Aris::Utils::Redirects)
53
+ redirect = Aris::Utils::Redirects.find(path)
54
+ if redirect
55
+ return { status: redirect[:status], headers: {'Location' => redirect[:to]}, body: [] }
56
+ end
57
+ end
58
+
59
+ route = Aris::Router.match(
60
+ domain: request_domain,
61
+ method: request.request_method.downcase.to_sym,
62
+ path: request.path_info
63
+ )
64
+
65
+ unless route
66
+ return format_response(Aris.not_found(request))
67
+ end
68
+
69
+ # Inject locale methods into request if locale is present
70
+ if route[:locale]
71
+ request.define_singleton_method(:locale) { route[:locale] }
72
+ if domain_config
73
+ request.define_singleton_method(:available_locales) { domain_config[:locales] }
74
+ request.define_singleton_method(:default_locale) { domain_config[:default_locale] }
75
+ end
76
+ end
77
+
78
+ response = Response.new
79
+ request.instance_variable_set(:@response, response)
80
+ request.define_singleton_method(:response) { @response }
81
+ # Execute plugins and handler via PipelineRunner
82
+ result = PipelineRunner.call(request: request, route: route, response: response)
83
+
84
+ # Format the result
85
+ format_response(result, response)
86
+
87
+ rescue Aris::Router::RouteNotFoundError
88
+ return format_response(Aris.not_found(request))
89
+ rescue Exception => e
90
+ return format_response(Aris.error(request, e))
91
+ ensure
92
+ Thread.current[:aris_current_domain] = nil
93
+ end
94
+ end
95
+
96
+ def subdomain
97
+ @subdomain || extract_subdomain_from_domain
98
+ end
99
+
100
+ private
101
+
102
+ def extract_subdomain_from_domain
103
+ return nil unless @env[:subdomain] || @env['SUBDOMAIN']
104
+
105
+ @env[:subdomain] || @env['SUBDOMAIN']
106
+ end
107
+
108
+ def parse_cookies(headers)
109
+ return {} unless headers && headers['Cookie']
110
+
111
+ cookies = {}
112
+ headers['Cookie'].split(';').each do |cookie|
113
+ name, value = cookie.strip.split('=', 2)
114
+ cookies[name] = value if name && value
115
+ end
116
+ cookies
117
+ end
118
+
119
+ def format_response(result, response = nil)
120
+ case result
121
+ when Response
122
+ { status: result.status, headers: result.headers, body: result.body }
123
+ when Array
124
+ # Don't unwrap - keep body as-is
125
+ { status: result[0], headers: result[1], body: result[2] }
126
+ when Hash
127
+ headers = response ? response.headers.merge({'content-type' => 'application/json'}) : {'content-type' => 'application/json'}
128
+ { status: 200, headers: headers, body: [result.to_json] }
129
+ else
130
+ if result.respond_to?(:status) && result.respond_to?(:headers) && result.respond_to?(:body)
131
+ { status: result.status, headers: result.headers, body: result.body }
132
+ else
133
+ headers = response ? response.headers.merge({'content-type' => 'text/plain'}) : {'content-type' => 'text/plain'}
134
+ { status: 200, headers: headers, body: [result.to_s] }
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end