aris 1.4.2 → 1.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +251 -0
- data/README.md +18 -0
- data/docs/ADAPTERS.md +478 -0
- data/docs/ARCHITECTURE.md +222 -0
- data/docs/CONTENT.md +967 -0
- data/docs/PERFORMANCE.md +492 -0
- data/docs/PLUGIN_DEVELOPMENT.md +688 -0
- data/docs/USAGE.md +4998 -0
- data/docs/plugins/API_KEY_AUTH.md +232 -0
- data/docs/plugins/BASIC_AUTH.md +582 -0
- data/docs/plugins/BEARER_AUTH.md +394 -0
- data/docs/plugins/CACHE.md +369 -0
- data/docs/plugins/COMPRESSION.md +216 -0
- data/docs/plugins/COOKIES.md +30 -0
- data/docs/plugins/CORS.md +283 -0
- data/docs/plugins/CSRF.md +751 -0
- data/docs/plugins/ETAG.md +308 -0
- data/docs/plugins/FORM_PARSER.md +193 -0
- data/docs/plugins/HEALTH_CHECK.md +469 -0
- data/docs/plugins/JSON.md +291 -0
- data/docs/plugins/MULTIPART.md +427 -0
- data/docs/plugins/RATE_LIMITER.md +368 -0
- data/docs/plugins/REQUEST_ID.md +369 -0
- data/docs/plugins/REQUEST_LOGGER.md +151 -0
- data/docs/plugins/SECURITY.md +193 -0
- data/docs/plugins/SESSION.md +98 -0
- data/lib/aris/adapters/rack/adapter.rb +17 -2
- data/lib/aris/adapters/rack/request.rb +29 -11
- data/lib/aris/plugins/basic_auth.rb +3 -1
- data/lib/aris/plugins/cookies.rb +4 -32
- data/lib/aris/plugins/cors.rb +8 -1
- data/lib/aris/plugins/csrf.rb +63 -22
- data/lib/aris/plugins/flash.rb +3 -1
- data/lib/aris/plugins/form_parser.rb +52 -31
- data/lib/aris/plugins/multipart.rb +22 -2
- data/lib/aris/plugins/request_logger.rb +8 -1
- data/lib/aris/plugins/security_headers.rb +8 -1
- data/lib/aris/plugins/session.rb +150 -99
- data/lib/aris/response_helpers.rb +41 -0
- data/lib/aris/version.rb +2 -2
- metadata +31 -3
data/docs/ADAPTERS.md
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# Adapter Architecture
|
|
2
|
+
|
|
3
|
+
Aris Router uses a **PipelineRunner** abstraction that makes it server-agnostic. The router core handles route matching, while adapters translate between server-specific I/O and the universal `Request`/`Response` interface.
|
|
4
|
+
|
|
5
|
+
## Architecture Overview
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ Aris::Router (Core) │
|
|
10
|
+
│ Domain/Path/Method Matching │
|
|
11
|
+
└─────────────────────────────────────────────────────────────┘
|
|
12
|
+
↓
|
|
13
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
14
|
+
│ Aris::PipelineRunner │
|
|
15
|
+
│ Executes Plugins → Handler → Response │
|
|
16
|
+
└─────────────────────────────────────────────────────────────┘
|
|
17
|
+
↓
|
|
18
|
+
┌──────────────────┐ ┌──────────────────┐ ┌────────────────┐
|
|
19
|
+
│ Rack Adapter │ │ Mock Adapter │ │ Your Adapter │
|
|
20
|
+
│ (Puma, Falcon) │ │ (Testing) │ │ (Agoo, etc) │
|
|
21
|
+
└──────────────────┘ └──────────────────┘ └────────────────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Key Concepts
|
|
25
|
+
|
|
26
|
+
### 1. PipelineRunner (Server-Agnostic Core)
|
|
27
|
+
|
|
28
|
+
Located in `lib/aris/pipeline_runner.rb`, this module executes the plugin pipeline and handler. It works with ANY adapter that provides a compatible Request/Response.
|
|
29
|
+
|
|
30
|
+
**Signature:**
|
|
31
|
+
```ruby
|
|
32
|
+
PipelineRunner.call(request: request, route: route, response: response)
|
|
33
|
+
# Returns: Response object, Array, Hash, or String
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Adapter Responsibilities
|
|
37
|
+
|
|
38
|
+
An adapter must:
|
|
39
|
+
1. Convert server-specific input → Aris Request
|
|
40
|
+
2. Set thread-local domain context
|
|
41
|
+
3. Match route via `Router.match`
|
|
42
|
+
4. Call `PipelineRunner.call`
|
|
43
|
+
5. Format PipelineRunner result → server-specific output
|
|
44
|
+
6. Clean up thread-local context
|
|
45
|
+
|
|
46
|
+
### 3. Request Interface Contract
|
|
47
|
+
|
|
48
|
+
Any Request class MUST implement these methods:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class YourAdapter::Request
|
|
52
|
+
# Required: HTTP method
|
|
53
|
+
def method
|
|
54
|
+
# Return: String (e.g., "GET", "POST")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def request_method
|
|
58
|
+
# Alias for method (some plugins use this)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Required: Path
|
|
62
|
+
def path
|
|
63
|
+
# Return: String (e.g., "/users/123")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def path_info
|
|
67
|
+
# Alias for path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Required: Domain/Host
|
|
71
|
+
def domain
|
|
72
|
+
# Return: String (e.g., "example.com")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def host
|
|
76
|
+
# Alias for domain
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Required: Query string
|
|
80
|
+
def query
|
|
81
|
+
# Return: String (e.g., "page=1&limit=10")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Required: Headers
|
|
85
|
+
def headers
|
|
86
|
+
# Return: Hash with header names as keys
|
|
87
|
+
# Example: { 'HTTP_AUTHORIZATION' => 'Bearer token' }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Required: Body
|
|
91
|
+
def body
|
|
92
|
+
# Return: String (raw request body)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Required: Query parameters
|
|
96
|
+
def params
|
|
97
|
+
# Return: Hash of parsed query string
|
|
98
|
+
# Example: { 'page' => '1', 'limit' => '10' }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Optional but recommended: Struct-like access
|
|
102
|
+
def [](key)
|
|
103
|
+
# Return value for :method, :domain, :path, etc.
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Required: Plugin data attachment
|
|
107
|
+
attr_accessor :json_body # For JSON parser plugin
|
|
108
|
+
# Plugins may use instance variables like @current_user
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 4. Response Interface Contract
|
|
113
|
+
|
|
114
|
+
Any Response class MUST have these attributes:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
class YourAdapter::Response
|
|
118
|
+
attr_accessor :status # Integer (e.g., 200, 404)
|
|
119
|
+
attr_accessor :headers # Hash (e.g., {'content-type' => 'application/json'})
|
|
120
|
+
attr_accessor :body # Array of strings (e.g., ['Hello'])
|
|
121
|
+
|
|
122
|
+
def initialize
|
|
123
|
+
@status = 200
|
|
124
|
+
@headers = {'content-type' => 'text/html'}
|
|
125
|
+
@body = []
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Building a Custom Adapter
|
|
131
|
+
|
|
132
|
+
### Example: Agoo Adapter
|
|
133
|
+
|
|
134
|
+
Here's how to create an adapter for Agoo (high-performance Ruby server):
|
|
135
|
+
|
|
136
|
+
**File Structure:**
|
|
137
|
+
```
|
|
138
|
+
lib/aris/adapters/agoo/
|
|
139
|
+
adapter.rb
|
|
140
|
+
request.rb
|
|
141
|
+
response.rb
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**1. Request Implementation (`agoo/request.rb`):**
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
module Aris
|
|
148
|
+
module Adapters
|
|
149
|
+
module Agoo
|
|
150
|
+
class Request
|
|
151
|
+
attr_reader :agoo_request
|
|
152
|
+
attr_accessor :json_body
|
|
153
|
+
|
|
154
|
+
def initialize(agoo_request)
|
|
155
|
+
@agoo_request = agoo_request
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def method
|
|
159
|
+
@agoo_request.request_method
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
alias_method :request_method, :method
|
|
163
|
+
|
|
164
|
+
def path
|
|
165
|
+
@agoo_request.path_info
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
alias_method :path_info, :path
|
|
169
|
+
|
|
170
|
+
def domain
|
|
171
|
+
@agoo_request.headers['Host'] || 'localhost'
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
alias_method :host, :domain
|
|
175
|
+
|
|
176
|
+
def query
|
|
177
|
+
@agoo_request.query_string || ''
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def headers
|
|
181
|
+
# Convert Agoo headers to standard format
|
|
182
|
+
@headers ||= @agoo_request.headers.transform_keys do |key|
|
|
183
|
+
"HTTP_#{key.upcase.gsub('-', '_')}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def body
|
|
188
|
+
@body ||= @agoo_request.body
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def params
|
|
192
|
+
@params ||= begin
|
|
193
|
+
return {} if query.empty?
|
|
194
|
+
query.split('&').each_with_object({}) do |pair, hash|
|
|
195
|
+
key, value = pair.split('=')
|
|
196
|
+
hash[key] = value if key
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def [](key)
|
|
202
|
+
case key
|
|
203
|
+
when :method then method
|
|
204
|
+
when :domain then domain
|
|
205
|
+
when :path then path
|
|
206
|
+
when :host then host
|
|
207
|
+
else nil
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**2. Response Implementation (`agoo/response.rb`):**
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
module Aris
|
|
220
|
+
module Adapters
|
|
221
|
+
module Agoo
|
|
222
|
+
class Response
|
|
223
|
+
attr_accessor :status, :headers, :body
|
|
224
|
+
|
|
225
|
+
def initialize
|
|
226
|
+
@status = 200
|
|
227
|
+
@headers = {'content-type' => 'text/html'}
|
|
228
|
+
@body = []
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**3. Adapter Implementation (`agoo/adapter.rb`):**
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
require_relative '../../core'
|
|
240
|
+
require_relative '../../pipeline_runner'
|
|
241
|
+
require_relative 'request'
|
|
242
|
+
require_relative 'response'
|
|
243
|
+
require 'json'
|
|
244
|
+
|
|
245
|
+
module Aris
|
|
246
|
+
module Adapters
|
|
247
|
+
module Agoo
|
|
248
|
+
class Adapter
|
|
249
|
+
def call(agoo_request)
|
|
250
|
+
request = Request.new(agoo_request)
|
|
251
|
+
request_domain = request.host
|
|
252
|
+
Thread.current[:aris_current_domain] = request_domain
|
|
253
|
+
|
|
254
|
+
begin
|
|
255
|
+
route = Aris::Router.match(
|
|
256
|
+
domain: request_domain,
|
|
257
|
+
method: request.request_method.downcase.to_sym,
|
|
258
|
+
path: request.path_info
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
unless route
|
|
262
|
+
return format_agoo_response(Aris.not_found(request))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
response = Response.new
|
|
266
|
+
|
|
267
|
+
# Core execution via PipelineRunner
|
|
268
|
+
result = PipelineRunner.call(
|
|
269
|
+
request: request,
|
|
270
|
+
route: route,
|
|
271
|
+
response: response
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
format_agoo_response(result, response)
|
|
275
|
+
|
|
276
|
+
rescue Aris::Router::RouteNotFoundError
|
|
277
|
+
return format_agoo_response(Aris.not_found(request))
|
|
278
|
+
rescue Exception => e
|
|
279
|
+
return format_agoo_response(Aris.error(request, e))
|
|
280
|
+
ensure
|
|
281
|
+
Thread.current[:aris_current_domain] = nil
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private
|
|
286
|
+
|
|
287
|
+
def format_agoo_response(result, response = nil)
|
|
288
|
+
case result
|
|
289
|
+
when Response
|
|
290
|
+
# Return Agoo-compatible response
|
|
291
|
+
[result.status, result.headers, result.body]
|
|
292
|
+
when Array
|
|
293
|
+
# Already in [status, headers, body] format
|
|
294
|
+
result
|
|
295
|
+
when Hash
|
|
296
|
+
headers = response ? response.headers.merge({'content-type' => 'application/json'}) : {'content-type' => 'application/json'}
|
|
297
|
+
[200, headers, [result.to_json]]
|
|
298
|
+
else
|
|
299
|
+
headers = response ? response.headers.merge({'content-type' => 'text/plain'}) : {'content-type' => 'text/plain'}
|
|
300
|
+
[200, headers, [result.to_s]]
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**4. Usage:**
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
require 'agoo'
|
|
313
|
+
require 'aris'
|
|
314
|
+
require 'aris/adapters/agoo/adapter'
|
|
315
|
+
|
|
316
|
+
# Define routes
|
|
317
|
+
Aris.routes({
|
|
318
|
+
"example.com": {
|
|
319
|
+
"/hello": {
|
|
320
|
+
get: { to: ->(req, params) { "Hello from Agoo!" } }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
# Start Agoo server
|
|
326
|
+
Agoo::Server.init(6464, 'root')
|
|
327
|
+
|
|
328
|
+
handler = Aris::Adapters::Agoo::Adapter.new
|
|
329
|
+
Agoo::Server.handle(:GET, '/hello', handler)
|
|
330
|
+
|
|
331
|
+
Agoo::Server.start
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Plugin Compatibility
|
|
335
|
+
|
|
336
|
+
**The beauty of this architecture:** Plugins work with ANY adapter without modification!
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
# This plugin works with Rack, Agoo, Mock, or any future adapter
|
|
340
|
+
bearer_auth = Aris::Plugins::BearerAuth.build(token: 'secret')
|
|
341
|
+
|
|
342
|
+
Aris.routes({
|
|
343
|
+
"api.example.com": {
|
|
344
|
+
use: [bearer_auth], # Works everywhere!
|
|
345
|
+
"/data": {
|
|
346
|
+
get: { to: DataHandler }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Why Plugins Are Adapter-Agnostic
|
|
353
|
+
|
|
354
|
+
Plugins only use the **interface contract** methods:
|
|
355
|
+
- `request.method` - Works in any adapter
|
|
356
|
+
- `request.headers` - Works in any adapter
|
|
357
|
+
- `request.body` - Works in any adapter
|
|
358
|
+
- `response.status = 401` - Works in any adapter
|
|
359
|
+
|
|
360
|
+
Example from `BearerAuth` plugin:
|
|
361
|
+
```ruby
|
|
362
|
+
def call(request, response)
|
|
363
|
+
auth_header = request.headers['HTTP_AUTHORIZATION'] # ← Interface method
|
|
364
|
+
|
|
365
|
+
if auth_header.nil?
|
|
366
|
+
response.status = 401 # ← Interface method
|
|
367
|
+
return response
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# ... validation logic
|
|
371
|
+
nil # Continue pipeline
|
|
372
|
+
end
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## Testing Your Adapter
|
|
376
|
+
|
|
377
|
+
### 1. Basic Functionality Test
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
class YourAdapterTest < Minitest::Test
|
|
381
|
+
def test_basic_request
|
|
382
|
+
Aris.routes({
|
|
383
|
+
"test.com": {
|
|
384
|
+
"/hello": { get: { to: ->(req, params) { "Hi!" } } }
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
adapter = YourAdapter::Adapter.new
|
|
389
|
+
result = adapter.call(your_request_format)
|
|
390
|
+
|
|
391
|
+
assert_equal 200, result[:status]
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### 2. Plugin Compatibility Test
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
def test_plugin_works
|
|
400
|
+
json_plugin = Aris::Plugins::Json
|
|
401
|
+
|
|
402
|
+
Aris.routes({
|
|
403
|
+
"test.com": {
|
|
404
|
+
use: [json_plugin],
|
|
405
|
+
"/api": { post: { to: ->(req, params) { req.json_body } } }
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
adapter = YourAdapter::Adapter.new
|
|
410
|
+
result = adapter.call(
|
|
411
|
+
method: 'POST',
|
|
412
|
+
body: '{"test": true}',
|
|
413
|
+
headers: { 'content-type' => 'application/json' }
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Plugin should have parsed JSON
|
|
417
|
+
assert_includes result[:body].first, 'test'
|
|
418
|
+
end
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
## Existing Adapters
|
|
422
|
+
|
|
423
|
+
### Rack Adapter (Production)
|
|
424
|
+
- **Location:** `lib/aris/adapters/rack/`
|
|
425
|
+
- **Servers:** Puma, Falcon, Unicorn, Passenger, WEBrick
|
|
426
|
+
- **Usage:** `Aris::Adapters::Rack::Adapter.new`
|
|
427
|
+
|
|
428
|
+
### Mock Adapter (Testing)
|
|
429
|
+
- **Location:** `lib/aris/adapters/mock/`
|
|
430
|
+
- **Purpose:** Unit testing without server overhead
|
|
431
|
+
- **Usage:** `Aris::Adapters::Mock::Adapter.new`
|
|
432
|
+
|
|
433
|
+
## Performance Considerations
|
|
434
|
+
|
|
435
|
+
1. **Request Object Caching:** Cache parsed headers, params, body to avoid re-parsing
|
|
436
|
+
2. **Thread-Local Cleanup:** ALWAYS clean up `Thread.current[:aris_current_domain]` in ensure block
|
|
437
|
+
3. **Minimal Allocations:** Reuse objects where possible in hot paths
|
|
438
|
+
4. **Lazy Parsing:** Don't parse body/params until accessed
|
|
439
|
+
|
|
440
|
+
## Adapter Checklist
|
|
441
|
+
|
|
442
|
+
When building a new adapter, verify:
|
|
443
|
+
|
|
444
|
+
- [ ] Request implements all required methods
|
|
445
|
+
- [ ] Response implements all required attributes
|
|
446
|
+
- [ ] Thread-local domain context is set/cleaned up
|
|
447
|
+
- [ ] Error handling (404/500) works
|
|
448
|
+
- [ ] Path parameters work (`/users/:id`)
|
|
449
|
+
- [ ] Query parameters work (`?page=1`)
|
|
450
|
+
- [ ] Request body is accessible
|
|
451
|
+
- [ ] Headers are accessible (with HTTP_ prefix convention)
|
|
452
|
+
- [ ] Plugins work (test with BearerAuth, Json, CORS)
|
|
453
|
+
- [ ] Response formatting handles all types (Response, Array, Hash, String)
|
|
454
|
+
|
|
455
|
+
## Future Adapter Ideas
|
|
456
|
+
|
|
457
|
+
- **Iodine:** Native C extension server
|
|
458
|
+
- **Falcon:** Async fiber-based server
|
|
459
|
+
- **Thin:** EventMachine-based server
|
|
460
|
+
- **Direct CGI:** For traditional CGI environments
|
|
461
|
+
- **Lambda/Serverless:** AWS Lambda, Google Cloud Functions
|
|
462
|
+
|
|
463
|
+
## Questions?
|
|
464
|
+
|
|
465
|
+
The adapter pattern is simple but powerful. If you're building a custom adapter and hit issues, check:
|
|
466
|
+
1. Does your Request implement ALL interface methods?
|
|
467
|
+
2. Is thread-local context cleaned up?
|
|
468
|
+
3. Can you run the Mock adapter tests with your adapter?
|
|
469
|
+
|
|
470
|
+
Happy adapting! 🚀
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
**Run tests to confirm docs are accurate:**
|
|
474
|
+
```bash
|
|
475
|
+
ruby test/run_all_tests.rb
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
✅ Once that passes, want me to create a quick `ARCHITECTURE.md` showing the overall system design?
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
**Create: `docs/ARCHITECTURE.md`**
|
|
2
|
+
|
|
3
|
+
```markdown
|
|
4
|
+
# Architecture
|
|
5
|
+
|
|
6
|
+
## 30-Second Overview
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
Router matches route → PipelineRunner executes plugins + handler → Adapter formats output
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**Key idea:** Router core knows nothing about servers. Adapters translate between servers and the universal Request/Response interface.
|
|
13
|
+
|
|
14
|
+
## The Stack
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
┌─────────────────────────────────────────┐
|
|
18
|
+
│ Adapter (Rack, Agoo, Mock, etc.) │ ← Server-specific I/O
|
|
19
|
+
├─────────────────────────────────────────┤
|
|
20
|
+
│ PipelineRunner │ ← Executes plugins → handler
|
|
21
|
+
├─────────────────────────────────────────┤
|
|
22
|
+
│ Router (Trie-based matching) │ ← domain/path/method → route
|
|
23
|
+
└─────────────────────────────────────────┘
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Core Components
|
|
27
|
+
|
|
28
|
+
### Router (`lib/aris/core.rb`)
|
|
29
|
+
- Trie-based route matching
|
|
30
|
+
- Compiles at startup (zero runtime parsing)
|
|
31
|
+
- Handles: domains, path params, wildcards, constraints
|
|
32
|
+
- Resolves plugin symbols → classes at compile time
|
|
33
|
+
|
|
34
|
+
### PipelineRunner (`lib/aris/pipeline_runner.rb`)
|
|
35
|
+
- Server-agnostic execution
|
|
36
|
+
- Runs plugin chain, calls handler
|
|
37
|
+
- Returns result (Response/Array/Hash/String)
|
|
38
|
+
|
|
39
|
+
### Adapters (`lib/aris/adapters/*`)
|
|
40
|
+
- Translate server input → Request
|
|
41
|
+
- Call PipelineRunner
|
|
42
|
+
- Format result → server output
|
|
43
|
+
- Manage thread-local context
|
|
44
|
+
|
|
45
|
+
### Plugins (`lib/aris/plugins/*`)
|
|
46
|
+
- Contract: `call(request, response) → nil or response`
|
|
47
|
+
- Return `nil` = continue, return `response` = halt
|
|
48
|
+
- Work with ANY adapter (zero coupling)
|
|
49
|
+
|
|
50
|
+
## Request Flow
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
1. HTTP request arrives
|
|
54
|
+
2. Adapter creates Request object
|
|
55
|
+
3. Router.match(domain, method, path) → route
|
|
56
|
+
4. PipelineRunner.call(request, route, response)
|
|
57
|
+
├─ Execute each plugin in route[:use]
|
|
58
|
+
├─ If plugin returns response → halt, return it
|
|
59
|
+
└─ Execute handler with params
|
|
60
|
+
5. Adapter formats result for server
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Design Decisions
|
|
64
|
+
|
|
65
|
+
**Hash-based config everywhere**
|
|
66
|
+
```ruby
|
|
67
|
+
plugin.build(token: 'secret') # NOT plugin.build { |c| c.token = 'secret' }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**No magic symbols/tags**
|
|
71
|
+
```ruby
|
|
72
|
+
use: [:csrf, bearer_auth] # :csrf from registry, bearer_auth is instance
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Compile-time resolution**
|
|
76
|
+
```ruby
|
|
77
|
+
Router.define(config) # Symbols resolve to classes here, not per-request
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Thread-local domain**
|
|
81
|
+
```ruby
|
|
82
|
+
Thread.current[:aris_current_domain] # Enables path helpers without passing domain
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Request mutation via ivars**
|
|
86
|
+
```ruby
|
|
87
|
+
request.instance_variable_set(:@current_user, user) # Plugins attach data
|
|
88
|
+
handler.instance_variable_get(:@current_user) # Handlers read it
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Plugin Contract
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class MyPlugin
|
|
95
|
+
def self.call(request, response)
|
|
96
|
+
# Read: request.method, request.path, request.headers, etc.
|
|
97
|
+
# Write: response.status, response.headers, response.body
|
|
98
|
+
|
|
99
|
+
return response if should_halt # Stop pipeline
|
|
100
|
+
nil # Continue
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.build(**config)
|
|
104
|
+
new(**config) # Config via keyword args
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Adapter Contract
|
|
110
|
+
|
|
111
|
+
**Request must implement:**
|
|
112
|
+
- `method`, `path`, `domain`, `query`, `headers`, `body`, `params`
|
|
113
|
+
- Aliases: `request_method`, `path_info`, `host`
|
|
114
|
+
- Mutable: `json_body` accessor (for JSON plugin)
|
|
115
|
+
|
|
116
|
+
**Response must have:**
|
|
117
|
+
- `status`, `headers`, `body` (all read/write)
|
|
118
|
+
|
|
119
|
+
**Adapter must:**
|
|
120
|
+
1. Create Request from server input
|
|
121
|
+
2. Set `Thread.current[:aris_current_domain]`
|
|
122
|
+
3. Call `Router.match` → get route
|
|
123
|
+
4. Call `PipelineRunner.call(request, route, response)`
|
|
124
|
+
5. Format result for server
|
|
125
|
+
6. Clean up thread-local in `ensure` block
|
|
126
|
+
|
|
127
|
+
## File Structure
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
lib/aris/
|
|
131
|
+
core.rb # Router + trie matching
|
|
132
|
+
pipeline_runner.rb # Plugin/handler execution
|
|
133
|
+
plugins.rb # Plugin registry
|
|
134
|
+
adapters/
|
|
135
|
+
rack/
|
|
136
|
+
adapter.rb # Rack server integration
|
|
137
|
+
request.rb # Rack env → Request
|
|
138
|
+
response.rb # Response object
|
|
139
|
+
mock/ # Testing adapter (no server)
|
|
140
|
+
...
|
|
141
|
+
plugins/
|
|
142
|
+
csrf.rb # CSRF protection
|
|
143
|
+
json.rb # JSON parser
|
|
144
|
+
bearer_auth.rb # Auth plugins
|
|
145
|
+
...
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Performance Notes
|
|
149
|
+
|
|
150
|
+
- Trie lookup: O(path segments)
|
|
151
|
+
- Plugin resolution: compile-time (zero runtime cost)
|
|
152
|
+
- No regex on hot path (only in constraints)
|
|
153
|
+
- Request caching: parse headers/params once
|
|
154
|
+
- Thread-local overhead: ~1-2 allocations/request
|
|
155
|
+
|
|
156
|
+
## Testing Strategy
|
|
157
|
+
|
|
158
|
+
- **Rack tests** (90%): Full integration via Rack adapter
|
|
159
|
+
- **Mock adapter** (10%): Proves abstraction works
|
|
160
|
+
- **Contract tests**: Verify Request/Response interface
|
|
161
|
+
|
|
162
|
+
All plugins tested once via Rack. Adapter tests are minimal.
|
|
163
|
+
|
|
164
|
+
## Why This Design?
|
|
165
|
+
|
|
166
|
+
**Problem:** Ruby servers (Rack, Agoo, Iodine) have different APIs
|
|
167
|
+
**Solution:** Adapter pattern + PipelineRunner abstraction
|
|
168
|
+
**Result:** Write plugins once, work everywhere
|
|
169
|
+
|
|
170
|
+
**Tradeoff:** Slight abstraction overhead vs. massive flexibility gain
|
|
171
|
+
|
|
172
|
+
## Next Steps
|
|
173
|
+
|
|
174
|
+
- **Using Rack?** Use `Aris::Adapters::Rack::Adapter` (works now)
|
|
175
|
+
- **Other server?** Build adapter (see `docs/ADAPTERS.md`)
|
|
176
|
+
- **New plugin?** Just implement `call(request, response)` contract
|
|
177
|
+
|
|
178
|
+
That's it. Simple, fast, composable. 🚀
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Create: `docs/README.md`** (index for docs)
|
|
182
|
+
|
|
183
|
+
```markdown
|
|
184
|
+
# Documentation
|
|
185
|
+
|
|
186
|
+
## Quick Links
|
|
187
|
+
|
|
188
|
+
- **[Architecture](ARCHITECTURE.md)** - How Aris Router works (5 min read)
|
|
189
|
+
- **[Adapters](ADAPTERS.md)** - Building custom server adapters (10 min read)
|
|
190
|
+
|
|
191
|
+
## Philosophy
|
|
192
|
+
|
|
193
|
+
- Zero magic
|
|
194
|
+
- Hash-based config
|
|
195
|
+
- Plugin composition
|
|
196
|
+
- Server agnostic
|
|
197
|
+
- Performance first
|
|
198
|
+
|
|
199
|
+
## Getting Started
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
require 'aris'
|
|
203
|
+
|
|
204
|
+
Aris.routes({
|
|
205
|
+
"example.com": {
|
|
206
|
+
use: [:json, :csrf], # Plugin chain
|
|
207
|
+
"/api/users/:id": {
|
|
208
|
+
get: { to: UsersHandler, as: :user }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
app = Aris::Adapters::Rack::Adapter.new
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Done.
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Run tests to confirm nothing broke:
|
|
220
|
+
```bash
|
|
221
|
+
ruby test/run_all_tests.rb
|
|
222
|
+
```
|