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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +251 -0
  3. data/README.md +18 -0
  4. data/docs/ADAPTERS.md +478 -0
  5. data/docs/ARCHITECTURE.md +222 -0
  6. data/docs/CONTENT.md +967 -0
  7. data/docs/PERFORMANCE.md +492 -0
  8. data/docs/PLUGIN_DEVELOPMENT.md +688 -0
  9. data/docs/USAGE.md +4998 -0
  10. data/docs/plugins/API_KEY_AUTH.md +232 -0
  11. data/docs/plugins/BASIC_AUTH.md +582 -0
  12. data/docs/plugins/BEARER_AUTH.md +394 -0
  13. data/docs/plugins/CACHE.md +369 -0
  14. data/docs/plugins/COMPRESSION.md +216 -0
  15. data/docs/plugins/COOKIES.md +30 -0
  16. data/docs/plugins/CORS.md +283 -0
  17. data/docs/plugins/CSRF.md +751 -0
  18. data/docs/plugins/ETAG.md +308 -0
  19. data/docs/plugins/FORM_PARSER.md +193 -0
  20. data/docs/plugins/HEALTH_CHECK.md +469 -0
  21. data/docs/plugins/JSON.md +291 -0
  22. data/docs/plugins/MULTIPART.md +427 -0
  23. data/docs/plugins/RATE_LIMITER.md +368 -0
  24. data/docs/plugins/REQUEST_ID.md +369 -0
  25. data/docs/plugins/REQUEST_LOGGER.md +151 -0
  26. data/docs/plugins/SECURITY.md +193 -0
  27. data/docs/plugins/SESSION.md +98 -0
  28. data/lib/aris/adapters/rack/adapter.rb +17 -2
  29. data/lib/aris/adapters/rack/request.rb +29 -11
  30. data/lib/aris/plugins/basic_auth.rb +3 -1
  31. data/lib/aris/plugins/cookies.rb +4 -32
  32. data/lib/aris/plugins/cors.rb +8 -1
  33. data/lib/aris/plugins/csrf.rb +63 -22
  34. data/lib/aris/plugins/flash.rb +3 -1
  35. data/lib/aris/plugins/form_parser.rb +52 -31
  36. data/lib/aris/plugins/multipart.rb +22 -2
  37. data/lib/aris/plugins/request_logger.rb +8 -1
  38. data/lib/aris/plugins/security_headers.rb +8 -1
  39. data/lib/aris/plugins/session.rb +150 -99
  40. data/lib/aris/response_helpers.rb +41 -0
  41. data/lib/aris/version.rb +2 -2
  42. 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.