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
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
# Plugin Development Guide
|
|
2
|
+
|
|
3
|
+
This guide covers building reusable plugins for Aris. It assumes you've read the plugin section in the main usage guide and understand the basics of how plugins work.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Plugin Registry
|
|
8
|
+
|
|
9
|
+
Plugins are registered with a symbol name for clean routing configuration. The registry resolves symbols to one or more plugin classes.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Single plugin
|
|
13
|
+
Aris.register_plugin(:json, plugin_class: Json)
|
|
14
|
+
|
|
15
|
+
# Multi-class plugin (like CSRF with generator + protection)
|
|
16
|
+
Aris.register_plugin(:csrf,
|
|
17
|
+
generator: CsrfTokenGenerator,
|
|
18
|
+
protection: CsrfProtection
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Usage in routes - symbols resolve to classes
|
|
22
|
+
Aris.routes({
|
|
23
|
+
"api.example.com": {
|
|
24
|
+
use: [:csrf, :json], # Expands to all plugin classes
|
|
25
|
+
"/users": { get: { to: UsersHandler } }
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Important:** All symbols in `use:` arrays must be registered plugins. Unregistered symbols will raise `ArgumentError: Unknown plugin :symbol_name`.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Design Principles
|
|
35
|
+
|
|
36
|
+
**Plugins should do one thing.** Don't build a plugin that handles authentication AND rate limiting AND logging. Build three plugins and compose them.
|
|
37
|
+
|
|
38
|
+
**Plugins should be stateless.** All state should live in the request, response, or explicitly passed dependencies. No class variables, no globals.
|
|
39
|
+
|
|
40
|
+
**Plugins should fail explicitly.** If something goes wrong, halt with a clear error response. Don't let bad requests reach handlers.
|
|
41
|
+
|
|
42
|
+
**Plugins should be fast.** They run on every request. Profile them. Optimize them. Cache aggressively.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## State Management: The Mutability Contract
|
|
47
|
+
|
|
48
|
+
Aris uses a strict contract to manage data flow: **Request data is immutable; Response state is mutable.**
|
|
49
|
+
|
|
50
|
+
| Object | State | How to Share/Access Data |
|
|
51
|
+
|:---|:---|:---|
|
|
52
|
+
| **`request` (Aris::Request)** | **Immutable** | Primary source of incoming data. Accessors: `request.method`, `request.path`, `request.headers`, `request.params`. |
|
|
53
|
+
| **`response` (Aris::Response)** | **Mutable** | Used to signal **HALT** (by returning the object) or **mutate final output** (`response.headers`, `response.status`, `response.body`). |
|
|
54
|
+
|
|
55
|
+
### Request Object Extension (The Cleanest Pattern)
|
|
56
|
+
|
|
57
|
+
For data that is part of the application context (like the current authenticated user), the cleanest approach is to extend the request object using instance variables.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
class AuthPlugin
|
|
61
|
+
def self.call(request, response)
|
|
62
|
+
token = request.headers['HTTP_AUTHORIZATION']
|
|
63
|
+
user = authenticate(token)
|
|
64
|
+
|
|
65
|
+
return unauthorized_response(response) unless user
|
|
66
|
+
|
|
67
|
+
# Attach user to request for handlers (low allocation)
|
|
68
|
+
request.instance_variable_set(:@current_user, user)
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.authenticate(token)
|
|
73
|
+
# Your auth logic
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.unauthorized_response(response)
|
|
77
|
+
response.status = 401
|
|
78
|
+
response.body = ['Unauthorized']
|
|
79
|
+
response
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# In your handler (accesses the injected user)
|
|
84
|
+
class UserHandler
|
|
85
|
+
def self.call(request, params)
|
|
86
|
+
# The handler must know the convention (@current_user)
|
|
87
|
+
current_user = request.instance_variable_get(:@current_user)
|
|
88
|
+
# Use current_user
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This pattern is clean but requires handlers to know about the plugin's conventions. Document what instance variables your plugin sets.
|
|
94
|
+
|
|
95
|
+
### Response Headers as Ephemeral State
|
|
96
|
+
|
|
97
|
+
The simplest pattern for sharing simple, string-based data (like timers, IDs, or debugging flags) is response headers.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class RequestTimer
|
|
101
|
+
def self.call(request, response)
|
|
102
|
+
# Store start time for duration calculation by a later plugin
|
|
103
|
+
response.headers['X-Request-Start'] = Time.now.to_f.to_s
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class ResponseTimer
|
|
109
|
+
def self.call(request, response)
|
|
110
|
+
if start = response.headers['X-Request-Start']
|
|
111
|
+
duration = Time.now.to_f - start.to_f
|
|
112
|
+
response.headers['X-Duration-Ms'] = (duration * 1000).round(2).to_s
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This works well for small amounts of data. Headers are visible in the response, so don't put sensitive data here unless you clean it up later.
|
|
120
|
+
|
|
121
|
+
### Thread-Local Storage (Use Sparingly)
|
|
122
|
+
|
|
123
|
+
For data that needs to be accessible deep in the call stack without passing it through every method, use thread-local storage.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class RequestContext
|
|
127
|
+
def self.call(request, response)
|
|
128
|
+
Thread.current[:request_id] = SecureRandom.uuid
|
|
129
|
+
Thread.current[:current_user] = authenticate(request)
|
|
130
|
+
nil
|
|
131
|
+
ensure
|
|
132
|
+
# CRITICAL: Always clean up thread-local state
|
|
133
|
+
Thread.current[:request_id] = nil
|
|
134
|
+
Thread.current[:current_user] = nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Accessible anywhere in the request
|
|
139
|
+
class DeepHandler
|
|
140
|
+
def self.call(request, params)
|
|
141
|
+
request_id = Thread.current[:request_id]
|
|
142
|
+
Logger.info("Request #{request_id}: Processing user #{params[:id]}")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Warning:** Always clean up thread-local state in an `ensure` block. Failing to do so causes state to leak between requests in threaded servers, leading to critical security and concurrency bugs.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Common Patterns
|
|
152
|
+
|
|
153
|
+
### Authentication
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
class BearerAuth
|
|
157
|
+
def self.call(request, response)
|
|
158
|
+
auth_header = request.headers['HTTP_AUTHORIZATION']
|
|
159
|
+
|
|
160
|
+
unless auth_header&.start_with?('Bearer ')
|
|
161
|
+
return halt_with(response, 401, 'Missing or invalid Authorization header')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
token = auth_header.sub('Bearer ', '')
|
|
165
|
+
user = User.find_by(api_token: token)
|
|
166
|
+
|
|
167
|
+
unless user
|
|
168
|
+
return halt_with(response, 401, 'Invalid token')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
request.instance_variable_set(:@current_user, user)
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def self.halt_with(response, status, message)
|
|
178
|
+
response.status = status
|
|
179
|
+
response.headers['content-type'] = 'application/json'
|
|
180
|
+
# Ensure body is correctly arrayed as per Rack standard
|
|
181
|
+
response.body = [%({"error": "#{message}"})]
|
|
182
|
+
response
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Rate Limiting
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
class RateLimiter
|
|
191
|
+
LIMIT = 100
|
|
192
|
+
WINDOW = 60 # seconds
|
|
193
|
+
|
|
194
|
+
def self.call(request, response)
|
|
195
|
+
key = rate_limit_key(request)
|
|
196
|
+
count = increment_count(key)
|
|
197
|
+
|
|
198
|
+
response.headers['X-RateLimit-Limit'] = LIMIT.to_s
|
|
199
|
+
response.headers['X-RateLimit-Remaining'] = [LIMIT - count, 0].max.to_s
|
|
200
|
+
|
|
201
|
+
if count > LIMIT
|
|
202
|
+
response.status = 429
|
|
203
|
+
response.headers['Retry-After'] = WINDOW.to_s
|
|
204
|
+
response.body = ['Rate limit exceeded']
|
|
205
|
+
return response
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def self.rate_limit_key(request)
|
|
214
|
+
# Use API key, IP, or user ID
|
|
215
|
+
request.headers['HTTP_X_API_KEY'] || request.headers['REMOTE_ADDR']
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def self.increment_count(key)
|
|
219
|
+
# Implement with Redis
|
|
220
|
+
REDIS.multi do
|
|
221
|
+
REDIS.incr("rate_limit:#{key}")
|
|
222
|
+
REDIS.expire("rate_limit:#{key}", WINDOW)
|
|
223
|
+
end.first
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### CORS Headers
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class Cors
|
|
232
|
+
ALLOWED_ORIGINS = ['https://example.com', 'https://app.example.com']
|
|
233
|
+
|
|
234
|
+
def self.call(request, response)
|
|
235
|
+
origin = request.headers['HTTP_ORIGIN']
|
|
236
|
+
|
|
237
|
+
if ALLOWED_ORIGINS.include?(origin)
|
|
238
|
+
response.headers['Access-Control-Allow-Origin'] = origin
|
|
239
|
+
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE'
|
|
240
|
+
response.headers['Access-Control-Allow-Headers'] = 'content-type, Authorization'
|
|
241
|
+
response.headers['Access-Control-Max-Age'] = '86400'
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Handle preflight requests
|
|
245
|
+
if request.method == 'OPTIONS'
|
|
246
|
+
response.status = 204
|
|
247
|
+
response.body = []
|
|
248
|
+
return response
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Request Logging
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
class RequestLogger
|
|
260
|
+
def self.call(request, response)
|
|
261
|
+
start_time = Time.now
|
|
262
|
+
|
|
263
|
+
# Store start time for later
|
|
264
|
+
response.headers['X-Request-Start'] = start_time.to_f.to_s
|
|
265
|
+
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
class ResponseLogger
|
|
271
|
+
def self.call(request, response)
|
|
272
|
+
start_time_str = response.headers.delete('X-Request-Start')
|
|
273
|
+
return nil unless start_time_str
|
|
274
|
+
|
|
275
|
+
duration = Time.now.to_f - start_time_str.to_f
|
|
276
|
+
|
|
277
|
+
Logger.info({
|
|
278
|
+
method: request.method,
|
|
279
|
+
path: request.path,
|
|
280
|
+
status: response.status,
|
|
281
|
+
duration_ms: (duration * 1000).round(2)
|
|
282
|
+
}.to_json)
|
|
283
|
+
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Use both together
|
|
289
|
+
Aris.routes({
|
|
290
|
+
"api.example.com": {
|
|
291
|
+
use: [RequestLogger, ResponseLogger],
|
|
292
|
+
# routes...
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Caching
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
class CachePlugin
|
|
301
|
+
def self.call(request, response)
|
|
302
|
+
# Only cache GET requests
|
|
303
|
+
return nil unless request.method == 'GET'
|
|
304
|
+
|
|
305
|
+
cache_key = "response:#{request.domain}:#{request.path}"
|
|
306
|
+
|
|
307
|
+
if cached = REDIS.get(cache_key)
|
|
308
|
+
response.status = 200
|
|
309
|
+
response.headers['content-type'] = 'application/json'
|
|
310
|
+
response.headers['X-Cache'] = 'HIT'
|
|
311
|
+
response.body = [cached]
|
|
312
|
+
return response
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Mark as cache miss for potential post-handler caching
|
|
316
|
+
response.headers['X-Cache'] = 'MISS'
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Plugin Composition
|
|
325
|
+
|
|
326
|
+
Plugins execute in order. Use this to build pipelines where later plugins depend on earlier ones.
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
Aris.routes({
|
|
330
|
+
"api.example.com": {
|
|
331
|
+
use: [
|
|
332
|
+
CorsHeaders, # Set headers first
|
|
333
|
+
BearerAuth, # Auth before rate limiting
|
|
334
|
+
RateLimiter, # Rate limit authenticated users
|
|
335
|
+
RequestLogger # Log after auth passes
|
|
336
|
+
],
|
|
337
|
+
# routes...
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Order matters. Expensive operations should come after cheap validation. If auth fails, you shouldn't hit the rate limiter or logger.
|
|
343
|
+
|
|
344
|
+
**All items in `use:` must be callable.** Use registered plugin symbols (`:csrf`, `:rate_limit`) or direct class references (`MyPlugin`). The router resolves symbols to classes at compile time, so there's no runtime overhead.
|
|
345
|
+
|
|
346
|
+
### Conditional Execution
|
|
347
|
+
|
|
348
|
+
Sometimes plugins need to behave differently based on context.
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
class ConditionalAuth
|
|
352
|
+
def self.call(request, response)
|
|
353
|
+
# Skip auth for public endpoints
|
|
354
|
+
return nil if public_path?(request.path)
|
|
355
|
+
|
|
356
|
+
# Run auth for everything else
|
|
357
|
+
auth_header = request.headers['HTTP_AUTHORIZATION']
|
|
358
|
+
return halt_unauthorized(response) unless valid_token?(auth_header)
|
|
359
|
+
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
private
|
|
364
|
+
|
|
365
|
+
def self.public_path?(path)
|
|
366
|
+
['/health', '/version', '/public/status'].include?(path)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Better yet, use route-level `use: nil` to clear inherited plugins entirely.
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
Aris.routes({
|
|
375
|
+
"api.example.com": {
|
|
376
|
+
use: [Auth, RateLimiter],
|
|
377
|
+
|
|
378
|
+
"/users": { get: { to: UsersHandler } },
|
|
379
|
+
|
|
380
|
+
"/health": {
|
|
381
|
+
use: nil, # Clear all plugins
|
|
382
|
+
get: { to: HealthHandler }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Testing Plugins
|
|
391
|
+
|
|
392
|
+
Test plugins in isolation with mock request and response objects.
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
require 'minitest/autorun'
|
|
396
|
+
|
|
397
|
+
class AuthPluginTest < Minitest::Test
|
|
398
|
+
def test_valid_token_continues
|
|
399
|
+
# Mocking only the necessary methods on the request object
|
|
400
|
+
request = mock_request(headers: {'HTTP_AUTHORIZATION' => 'Bearer valid-token'})
|
|
401
|
+
response = Aris::Response.new
|
|
402
|
+
|
|
403
|
+
result = BearerAuth.call(request, response)
|
|
404
|
+
|
|
405
|
+
assert_nil result, "Should return nil to continue processing"
|
|
406
|
+
assert_equal 200, response.status
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def test_invalid_token_halts
|
|
410
|
+
request = mock_request(headers: {'HTTP_AUTHORIZATION' => 'Bearer invalid'})
|
|
411
|
+
response = Aris::Response.new
|
|
412
|
+
|
|
413
|
+
result = BearerAuth.call(request, response)
|
|
414
|
+
|
|
415
|
+
assert_equal response, result, "Should return response object to halt"
|
|
416
|
+
assert_equal 401, response.status
|
|
417
|
+
assert_match /Invalid token/, response.body.first
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def test_missing_token_halts
|
|
421
|
+
request = mock_request(headers: {})
|
|
422
|
+
response = Aris::Response.new
|
|
423
|
+
|
|
424
|
+
result = BearerAuth.call(request, response)
|
|
425
|
+
|
|
426
|
+
assert_equal 401, response.status
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
private
|
|
430
|
+
|
|
431
|
+
def mock_request(headers: {})
|
|
432
|
+
# Minimal mocking utility
|
|
433
|
+
req = Object.new
|
|
434
|
+
req.define_singleton_method(:headers) { headers }
|
|
435
|
+
req.define_singleton_method(:method) { 'GET' }
|
|
436
|
+
req
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
For integration tests, test the full plugin chain with real routes.
|
|
442
|
+
|
|
443
|
+
```ruby
|
|
444
|
+
class PluginIntegrationTest < Minitest::Test
|
|
445
|
+
def setup
|
|
446
|
+
Aris.routes({
|
|
447
|
+
"api.example.com": {
|
|
448
|
+
use: [BearerAuth, RateLimiter],
|
|
449
|
+
"/users": { get: { to: UsersHandler } }
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
@app = Aris::Adapters::RackApp.new
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def test_authenticated_request_succeeds
|
|
457
|
+
env = build_env('/users', 'GET', 'Bearer valid-token')
|
|
458
|
+
status, headers, body = @app.call(env)
|
|
459
|
+
|
|
460
|
+
assert_equal 200, status
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def test_unauthenticated_request_fails
|
|
464
|
+
env = build_env('/users', 'GET', nil)
|
|
465
|
+
status, headers, body = @app.call(env)
|
|
466
|
+
|
|
467
|
+
assert_equal 401, status
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
private
|
|
471
|
+
|
|
472
|
+
def build_env(path, method, auth)
|
|
473
|
+
{
|
|
474
|
+
'REQUEST_METHOD' => method,
|
|
475
|
+
'PATH_INFO' => path,
|
|
476
|
+
'HTTP_HOST' => 'api.example.com',
|
|
477
|
+
'HTTP_AUTHORIZATION' => auth,
|
|
478
|
+
'rack.input' => StringIO.new('')
|
|
479
|
+
}
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## Performance Considerations
|
|
487
|
+
|
|
488
|
+
Plugins run on every request. Profile them and optimize aggressively.
|
|
489
|
+
|
|
490
|
+
### Avoid N+1 Queries
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
# Bad - queries database on every request
|
|
494
|
+
class BadAuth
|
|
495
|
+
def self.call(request, response)
|
|
496
|
+
token = request.headers['HTTP_AUTHORIZATION']
|
|
497
|
+
user = User.find_by(api_token: token) # DB query
|
|
498
|
+
# ...
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Better - cache user lookups in a shared CACHE layer
|
|
503
|
+
class BetterAuth
|
|
504
|
+
def self.call(request, response)
|
|
505
|
+
token = request.headers['HTTP_AUTHORIZATION']
|
|
506
|
+
user = cached_user_lookup(token) # Check cache before DB
|
|
507
|
+
# ...
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def self.cached_user_lookup(token)
|
|
511
|
+
cache_key = "user:token:#{token}"
|
|
512
|
+
CACHE.fetch(cache_key, expires_in: 300) do
|
|
513
|
+
User.find_by(api_token: token)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Minimize Object Allocation
|
|
520
|
+
|
|
521
|
+
```ruby
|
|
522
|
+
# Bad - creates new strings on every request
|
|
523
|
+
class BadLogger
|
|
524
|
+
def self.call(request, response)
|
|
525
|
+
Logger.info("Request: " + request.method + " " + request.path)
|
|
526
|
+
nil
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Better - use string interpolation (single allocation)
|
|
531
|
+
class BetterLogger
|
|
532
|
+
def self.call(request, response)
|
|
533
|
+
Logger.info("Request: #{request.method} #{request.path}")
|
|
534
|
+
nil
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Best - reuse format string
|
|
539
|
+
class BestLogger
|
|
540
|
+
FORMAT = "Request: %s %s"
|
|
541
|
+
|
|
542
|
+
def self.call(request, response)
|
|
543
|
+
Logger.info(FORMAT % [request.method, request.path])
|
|
544
|
+
nil
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### Early Exit
|
|
550
|
+
|
|
551
|
+
Check the cheapest conditions first and exit early when possible.
|
|
552
|
+
|
|
553
|
+
```ruby
|
|
554
|
+
class OptimizedAuth
|
|
555
|
+
def self.call(request, response)
|
|
556
|
+
# 1. Check for header existence (cheapest check)
|
|
557
|
+
auth_header = request.headers['HTTP_AUTHORIZATION']
|
|
558
|
+
return halt_unauthorized(response) unless auth_header
|
|
559
|
+
|
|
560
|
+
# 2. Check prefix (cheap string comparison)
|
|
561
|
+
return halt_unauthorized(response) unless auth_header.start_with?('Bearer ')
|
|
562
|
+
|
|
563
|
+
# 3. Check token length (cheap computation)
|
|
564
|
+
token = auth_header.sub('Bearer ', '')
|
|
565
|
+
return halt_unauthorized(response) unless token.length > 20
|
|
566
|
+
|
|
567
|
+
# 4. Database lookup (most expensive check, performed last)
|
|
568
|
+
user = User.find_by(api_token: token)
|
|
569
|
+
return halt_unauthorized(response) unless user
|
|
570
|
+
|
|
571
|
+
request.instance_variable_set(:@current_user, user)
|
|
572
|
+
nil
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
## Distributing Plugins
|
|
580
|
+
|
|
581
|
+
If you're building plugins for others to use, follow these conventions.
|
|
582
|
+
|
|
583
|
+
### Structure
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
# lib/aris/plugins/my_plugin.rb
|
|
587
|
+
module Aris
|
|
588
|
+
module Plugins
|
|
589
|
+
class MyPlugin
|
|
590
|
+
def self.call(request, response)
|
|
591
|
+
# Implementation
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Configuration
|
|
599
|
+
|
|
600
|
+
Make plugins configurable without using globals.
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
# Bad - uses class variables
|
|
604
|
+
class Configurable
|
|
605
|
+
@@api_key = nil
|
|
606
|
+
|
|
607
|
+
def self.api_key=(key)
|
|
608
|
+
@@api_key = key
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def self.call(request, response)
|
|
612
|
+
# Uses @@api_key
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Better - use initialization
|
|
617
|
+
class Configurable
|
|
618
|
+
def initialize(api_key:)
|
|
619
|
+
@api_key = api_key
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def call(request, response)
|
|
623
|
+
# Uses @api_key
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Usage
|
|
628
|
+
Aris.routes({
|
|
629
|
+
"api.example.com": {
|
|
630
|
+
use: [Configurable.new(api_key: ENV['API_KEY'])],
|
|
631
|
+
# routes...
|
|
632
|
+
}
|
|
633
|
+
})
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### Documentation
|
|
637
|
+
|
|
638
|
+
Document what your plugin does, what it requires, and what side effects it has.
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
# Authenticates requests using bearer tokens from the Authorization header.
|
|
642
|
+
#
|
|
643
|
+
# Requirements:
|
|
644
|
+
# - User model with `api_token` column
|
|
645
|
+
# - Authorization header in format: "Bearer <token>"
|
|
646
|
+
#
|
|
647
|
+
# Side effects:
|
|
648
|
+
# - Sets @current_user instance variable on request object
|
|
649
|
+
# - Returns 401 response for missing/invalid tokens
|
|
650
|
+
#
|
|
651
|
+
# Example:
|
|
652
|
+
# Aris.routes({
|
|
653
|
+
# "api.example.com": {
|
|
654
|
+
# use: [BearerAuth],
|
|
655
|
+
# "/users": { get: { to: UsersHandler } }
|
|
656
|
+
# }
|
|
657
|
+
# })
|
|
658
|
+
class BearerAuth
|
|
659
|
+
# ...
|
|
660
|
+
end
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
When distributing plugins, users must register them before using them in routes. Include registration instructions in your documentation:
|
|
664
|
+
|
|
665
|
+
```ruby
|
|
666
|
+
# Installation (in user's code)
|
|
667
|
+
require 'aris/plugins/my_plugin'
|
|
668
|
+
Aris.register_plugin(:my_plugin, plugin_class: MyPlugin)
|
|
669
|
+
```
|
|
670
|
+
---
|
|
671
|
+
|
|
672
|
+
## Common Pitfalls
|
|
673
|
+
|
|
674
|
+
**Using class variables for state** - They leak between requests. Use the request/response objects or thread-local storage.
|
|
675
|
+
|
|
676
|
+
**Forgetting to return nil** - If you don't explicitly return nil or the response, Ruby returns the last expression, which can cause unexpected halts.
|
|
677
|
+
|
|
678
|
+
**Expensive operations before auth** - Always authenticate before doing expensive work like database queries or external API calls.
|
|
679
|
+
|
|
680
|
+
**Not cleaning up thread-local state** - Always use ensure blocks when setting thread-local variables.
|
|
681
|
+
|
|
682
|
+
**Halting without setting response body** - Always set status, headers, and body when halting. Empty bodies can cause issues with some clients.
|
|
683
|
+
|
|
684
|
+
**Modifying request.params** - The params hash comes from routing. Don't mutate it. If you need to add data, use instance variables on the request object.
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
That covers the essential patterns for building robust, performant plugins. The key insight is that plugins are just functions with a contract—keep them simple, stateless, and fast, and they'll serve you well.
|