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,369 @@
1
+ # Response Caching Plugin
2
+
3
+ In-memory response caching for GET requests. Dramatically improves performance by serving cached responses without executing handlers or database queries.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ require 'aris/plugins/cache'
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```ruby
14
+ cache = Aris::Plugins::Cache.build(ttl: 60) # Cache for 60 seconds
15
+
16
+ Aris.routes({
17
+ "api.example.com": {
18
+ use: [cache],
19
+ "/users": { get: { to: UsersHandler } }
20
+ }
21
+ })
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ | Option | Type | Default | Description |
27
+ |--------|------|---------|-------------|
28
+ | `ttl` | Integer | `60` | Time-to-live in seconds |
29
+ | `store` | Hash | `{}` | Custom cache store (in-memory hash by default) |
30
+ | `skip_paths` | Array | `[]` | Regex patterns of paths to skip caching |
31
+ | `cache_control` | String | `nil` | Cache-Control header value to set |
32
+
33
+ ## How It Works
34
+
35
+ 1. **Cache Miss (First Request)**
36
+ - Handler executes normally
37
+ - Response cached with TTL
38
+ - `X-Cache: MISS` header added
39
+
40
+ 2. **Cache Hit (Subsequent Requests)**
41
+ - Cached response returned instantly
42
+ - Handler NOT executed
43
+ - `X-Cache: HIT` header added
44
+
45
+ 3. **Cache Expiry**
46
+ - After TTL expires, cache miss occurs
47
+ - Handler executes, new response cached
48
+
49
+ ## Examples
50
+
51
+ ### Basic Caching
52
+
53
+ ```ruby
54
+ cache = Aris::Plugins::Cache.build(ttl: 300) # 5 minutes
55
+
56
+ Aris.routes({
57
+ "api.example.com": {
58
+ use: [cache],
59
+ "/products": { get: { to: ProductsHandler } }
60
+ }
61
+ })
62
+
63
+ # First request: Handler executes, 100ms
64
+ # Second request: Cached, 1ms (100x faster!)
65
+ ```
66
+
67
+ ### Different TTLs per Route
68
+
69
+ ```ruby
70
+ short_cache = Aris::Plugins::Cache.build(ttl: 60) # 1 minute
71
+ long_cache = Aris::Plugins::Cache.build(ttl: 3600) # 1 hour
72
+
73
+ Aris.routes({
74
+ "api.example.com": {
75
+ "/users": {
76
+ use: [short_cache],
77
+ get: { to: UsersHandler }
78
+ },
79
+ "/static": {
80
+ use: [long_cache],
81
+ get: { to: StaticHandler }
82
+ }
83
+ }
84
+ })
85
+ ```
86
+
87
+ ### Skip Certain Paths
88
+
89
+ ```ruby
90
+ cache = Aris::Plugins::Cache.build(
91
+ ttl: 60,
92
+ skip_paths: [
93
+ /^\/admin/, # Skip /admin/*
94
+ /^\/health/, # Skip health checks
95
+ /^\/auth/ # Skip auth endpoints
96
+ ]
97
+ )
98
+
99
+ Aris.routes({
100
+ "api.example.com": {
101
+ use: [cache],
102
+ "/users": { get: { to: UsersHandler } }, # Cached
103
+ "/admin": { get: { to: AdminHandler } }, # NOT cached
104
+ "/health": { get: { to: HealthHandler } } # NOT cached
105
+ }
106
+ })
107
+ ```
108
+
109
+ ### Custom Cache-Control Headers
110
+
111
+ ```ruby
112
+ cache = Aris::Plugins::Cache.build(
113
+ ttl: 300,
114
+ cache_control: 'public, max-age=300, s-maxage=600'
115
+ )
116
+
117
+ # Response includes:
118
+ # Cache-Control: public, max-age=300, s-maxage=600
119
+ # (CDNs and browsers can cache too)
120
+ ```
121
+
122
+ ### Bypass Cache with Header
123
+
124
+ ```ruby
125
+ # Client sends:
126
+ # Cache-Control: no-cache
127
+
128
+ # Cache is bypassed, fresh response generated
129
+ ```
130
+
131
+ ## Cache Key Generation
132
+
133
+ Keys are generated from:
134
+ - Domain
135
+ - Path
136
+ - Query string
137
+
138
+ ```ruby
139
+ # Different cache entries:
140
+ GET /users → cache_key_1
141
+ GET /users?page=2 → cache_key_2
142
+ GET /products → cache_key_3
143
+ ```
144
+
145
+ ## Production Tips
146
+
147
+ ### 1. Choose TTL Wisely
148
+
149
+ **Highly dynamic data:**
150
+ ```ruby
151
+ cache = Cache.build(ttl: 10) # 10 seconds
152
+ ```
153
+
154
+ **Frequently changing:**
155
+ ```ruby
156
+ cache = Cache.build(ttl: 60) # 1 minute
157
+ ```
158
+
159
+ **Rarely changing:**
160
+ ```ruby
161
+ cache = Cache.build(ttl: 3600) # 1 hour
162
+ ```
163
+
164
+ **Static data:**
165
+ ```ruby
166
+ cache = Cache.build(ttl: 86400) # 24 hours
167
+ ```
168
+
169
+ ### 2. Use Redis for Multi-Server
170
+
171
+ In-memory cache doesn't work across servers. Use Redis:
172
+
173
+ ```ruby
174
+ require 'redis'
175
+
176
+ redis_store = Redis.new(url: ENV['REDIS_URL'])
177
+
178
+ cache = Aris::Plugins::Cache.build(
179
+ ttl: 300,
180
+ store: redis_store # Shared across all servers
181
+ )
182
+ ```
183
+
184
+ **Note:** Current implementation uses Hash. You'd need to adapt it for Redis compatibility.
185
+
186
+ ### 3. Skip Non-Cacheable Endpoints
187
+
188
+ ```ruby
189
+ cache = Cache.build(
190
+ ttl: 60,
191
+ skip_paths: [
192
+ /^\/admin/, # Admin panels
193
+ /^\/auth/, # Authentication
194
+ /^\/checkout/, # Checkout flows
195
+ /^\/cart/, # Shopping carts
196
+ /\/me$/, # User-specific
197
+ /^\/health/, # Health checks
198
+ /^\/metrics/ # Monitoring
199
+ ]
200
+ )
201
+ ```
202
+
203
+ ### 4. Combine with ETag
204
+
205
+ ```ruby
206
+ etag = Aris::Plugins::ETag.build
207
+ cache = Aris::Plugins::Cache.build(ttl: 300)
208
+
209
+ Aris.routes({
210
+ "api.example.com": {
211
+ use: [cache, etag], # Cache first, then ETag
212
+ "/data": { get: { to: DataHandler } }
213
+ }
214
+ })
215
+
216
+ # Flow:
217
+ # 1. Check cache (X-Cache: HIT)
218
+ # 2. Check ETag (304 Not Modified)
219
+ # 3. Bandwidth saved twice!
220
+ ```
221
+
222
+ ### 5. Monitor Cache Hit Rate
223
+
224
+ ```ruby
225
+ class CacheMetrics
226
+ def self.call(request, response)
227
+ cache_status = response.headers['X-Cache']
228
+
229
+ StatsD.increment('cache.hits') if cache_status == 'HIT'
230
+ StatsD.increment('cache.misses') if cache_status == 'MISS'
231
+
232
+ nil
233
+ end
234
+ end
235
+
236
+ # Target: 60-80% hit rate for cacheable endpoints
237
+ ```
238
+
239
+ ### 6. Vary by User
240
+
241
+ User-specific data needs per-user cache keys:
242
+
243
+ ```ruby
244
+ class UserCache
245
+ def initialize(**config)
246
+ @cache = Aris::Plugins::Cache.build(**config)
247
+ end
248
+
249
+ def call(request, response)
250
+ # Add user ID to cache key
251
+ user_id = request.instance_variable_get(:@current_user)&.id
252
+ request.instance_variable_set(:@cache_suffix, user_id)
253
+
254
+ @cache.call(request, response)
255
+ end
256
+ end
257
+ ```
258
+
259
+ ### 7. Clear Cache on Updates
260
+
261
+ ```ruby
262
+ class UsersHandler
263
+ def self.update(request, params)
264
+ user = User.update(params[:id], request.json_body)
265
+
266
+ # Clear cache for this user
267
+ cache.clear! # Or selective clear
268
+
269
+ { user: user }
270
+ end
271
+ end
272
+ ```
273
+
274
+ ## Performance Benchmarks
275
+
276
+ **Example API endpoint:**
277
+ - Without cache: 100ms (database query + JSON serialization)
278
+ - With cache: 1-2ms (memory lookup)
279
+ - **50-100x speedup**
280
+
281
+ **Typical cache hit rates:**
282
+ - Public APIs: 70-90%
283
+ - Authenticated APIs: 40-60%
284
+ - Admin panels: 10-30%
285
+
286
+ ## Common Patterns
287
+
288
+ ### Tiered Caching
289
+
290
+ ```ruby
291
+ fast_cache = Cache.build(ttl: 30) # 30s for hot data
292
+ slow_cache = Cache.build(ttl: 3600) # 1h for cold data
293
+
294
+ Aris.routes({
295
+ "api.example.com": {
296
+ "/trending": { use: [fast_cache], get: { to: TrendingHandler } },
297
+ "/archive": { use: [slow_cache], get: { to: ArchiveHandler } }
298
+ }
299
+ })
300
+ ```
301
+
302
+ ### Cache Warming
303
+
304
+ ```ruby
305
+ # On deploy/startup
306
+ cache = Cache.build(ttl: 300)
307
+
308
+ popular_paths = ['/products', '/categories', '/home']
309
+ popular_paths.each do |path|
310
+ # Make request to warm cache
311
+ app.call(build_env(path))
312
+ end
313
+ ```
314
+
315
+ ### Conditional Caching
316
+
317
+ ```ruby
318
+ class SmartCache
319
+ def call(request, response)
320
+ # Only cache successful responses
321
+ return nil unless response.status == 200
322
+
323
+ # Only cache small responses
324
+ return nil if response.body.join.bytesize > 100_000
325
+
326
+ # Proceed with caching
327
+ @cache.call(request, response)
328
+ end
329
+ end
330
+ ```
331
+
332
+ ## Notes
333
+
334
+ - Only caches GET requests (POST/PUT/PATCH/DELETE not cached)
335
+ - Only caches 200 OK responses
336
+ - In-memory by default (not shared across servers)
337
+ - Thread-safe (uses Mutex)
338
+ - Respects `Cache-Control: no-cache` from client
339
+ - Cache key includes domain, path, and query string
340
+ - Expired entries are removed on access
341
+
342
+ ## Limitations
343
+
344
+ 1. **In-memory only** - Doesn't persist across restarts
345
+ 2. **No cache size limit** - Could grow unbounded (add LRU if needed)
346
+ 3. **No cache invalidation** - Only expires via TTL
347
+ 4. **Single-server** - Doesn't work across multiple servers
348
+
349
+ For production with multiple servers, integrate Redis or Memcached.
350
+
351
+ ## Troubleshooting
352
+
353
+ **Cache not working?**
354
+ - Verify request method is GET
355
+ - Check response status is 200
356
+ - Ensure path isn't in `skip_paths`
357
+ - Look for `X-Cache` header in response
358
+
359
+ **Low cache hit rate?**
360
+ - Check if data changes frequently
361
+ - Verify TTL isn't too short
362
+ - Look for query string variations
363
+ - Monitor cache expiry rate
364
+
365
+ **Memory issues?**
366
+ - Reduce TTL
367
+ - Add cache size limits
368
+ - Use Redis instead of in-memory
369
+ - Skip large responses
@@ -0,0 +1,216 @@
1
+ # Response Compression Plugin
2
+
3
+ Automatically compresses HTTP responses using gzip to reduce bandwidth usage by 60-80%.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ require 'aris/plugins/compression'
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```ruby
14
+ compression = Aris::Plugins::Compression.build
15
+
16
+ Aris.routes({
17
+ "api.example.com": {
18
+ use: [compression], # Apply to all routes in domain
19
+ "/data": { get: { to: DataHandler } }
20
+ }
21
+ })
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ | Option | Type | Default | Description |
27
+ |--------|------|---------|-------------|
28
+ | `level` | Integer | `Zlib::DEFAULT_COMPRESSION` | Compression level (0-9, higher = better compression but slower) |
29
+ | `min_size` | Integer | `1024` | Minimum response size in bytes to compress |
30
+
31
+ ### Compression Levels
32
+
33
+ - `Zlib::NO_COMPRESSION` (0) - No compression
34
+ - `Zlib::BEST_SPEED` (1) - Fastest, least compression
35
+ - `Zlib::DEFAULT_COMPRESSION` (6) - Balanced (default)
36
+ - `Zlib::BEST_COMPRESSION` (9) - Slowest, best compression
37
+
38
+ ## Examples
39
+
40
+ ### Default Compression
41
+
42
+ ```ruby
43
+ compression = Aris::Plugins::Compression.build
44
+
45
+ # Compresses responses > 1KB with default level
46
+ ```
47
+
48
+ ### High Compression
49
+
50
+ ```ruby
51
+ compression = Aris::Plugins::Compression.build(
52
+ level: Zlib::BEST_COMPRESSION, # Maximum compression
53
+ min_size: 512 # Compress anything > 512 bytes
54
+ )
55
+ ```
56
+
57
+ ### Fast Compression
58
+
59
+ ```ruby
60
+ compression = Aris::Plugins::Compression.build(
61
+ level: Zlib::BEST_SPEED, # Fast compression
62
+ min_size: 2048 # Only compress larger responses
63
+ )
64
+ ```
65
+
66
+ ### Selective Compression
67
+
68
+ ```ruby
69
+ # Compress API responses but not static assets
70
+ api_compression = Aris::Plugins::Compression.build
71
+
72
+ Aris.routes({
73
+ "api.example.com": {
74
+ use: [api_compression],
75
+ "/users": { get: { to: UsersHandler } }
76
+ },
77
+ "static.example.com": {
78
+ use: nil, # No compression for static domain
79
+ "/assets/*path": { get: { to: StaticHandler } }
80
+ }
81
+ })
82
+ ```
83
+
84
+ ## How It Works
85
+
86
+ 1. **Checks client support**: Only compresses if `Accept-Encoding: gzip` header present
87
+ 2. **Size threshold**: Skips responses smaller than `min_size` (overhead not worth it)
88
+ 3. **content-type filter**: Only compresses text-based types (JSON, HTML, JS, CSS, XML)
89
+ 4. **Smart compression**: Skips compression if it makes response larger
90
+ 5. **Header management**: Sets `Content-Encoding: gzip`, adds `Vary: Accept-Encoding`
91
+
92
+ ## Compressible Content Types
93
+
94
+ Automatically compresses:
95
+ - `text/*` (HTML, CSS, plain text)
96
+ - `application/json`
97
+ - `application/javascript`
98
+ - `application/xml`
99
+ - `application/xhtml+xml`
100
+
101
+ Binary content (images, video, PDFs) is skipped.
102
+
103
+ ## Production Tips
104
+
105
+ ### 1. Tune Compression Level
106
+
107
+ **High traffic, CPU-bound:**
108
+ ```ruby
109
+ Compression.build(level: Zlib::BEST_SPEED) # Faster, less CPU
110
+ ```
111
+
112
+ **Bandwidth-constrained:**
113
+ ```ruby
114
+ Compression.build(level: Zlib::BEST_COMPRESSION) # Smaller, more CPU
115
+ ```
116
+
117
+ **Balanced (recommended):**
118
+ ```ruby
119
+ Compression.build # Default level 6
120
+ ```
121
+
122
+ ### 2. Adjust Minimum Size
123
+
124
+ Small responses have compression overhead:
125
+ ```ruby
126
+ # Conservative (default)
127
+ Compression.build(min_size: 1024)
128
+
129
+ # Aggressive (compress more)
130
+ Compression.build(min_size: 512)
131
+
132
+ # Very conservative (only large responses)
133
+ Compression.build(min_size: 4096)
134
+ ```
135
+
136
+ ### 3. Order in Plugin Chain
137
+
138
+ Place **after** plugins that modify body, **before** logging:
139
+
140
+ ```ruby
141
+ Aris.routes({
142
+ "api.example.com": {
143
+ use: [
144
+ json_parser, # Parse request body
145
+ bearer_auth, # Authenticate
146
+ compression, # ← Compress response (late in chain)
147
+ request_logger # Log (sees compressed size)
148
+ ]
149
+ }
150
+ })
151
+ ```
152
+
153
+ ### 4. CDN Compatibility
154
+
155
+ If using a CDN that compresses:
156
+ ```ruby
157
+ # Let CDN handle it
158
+ use: nil
159
+
160
+ # Or compress at origin for edge caching
161
+ compression = Compression.build(level: Zlib::BEST_COMPRESSION)
162
+ ```
163
+
164
+ ### 5. Monitoring
165
+
166
+ Track compression ratio:
167
+ ```ruby
168
+ # Before compression
169
+ original_size = response.body.join.bytesize
170
+
171
+ # After compression (in logs)
172
+ compressed_size = response.body.first.bytesize
173
+
174
+ ratio = (1 - compressed_size.to_f / original_size) * 100
175
+ # Typical: 70-80% reduction for JSON/text
176
+ ```
177
+
178
+ ## Benchmarks
179
+
180
+ Typical compression ratios:
181
+ - JSON APIs: 75-85% reduction
182
+ - HTML pages: 65-75% reduction
183
+ - JavaScript: 60-70% reduction
184
+ - Plain text: 50-70% reduction
185
+
186
+ Performance impact:
187
+ - Level 1: ~0.1ms overhead per response
188
+ - Level 6: ~0.5ms overhead per response
189
+ - Level 9: ~2ms overhead per response
190
+
191
+ *Based on 10KB responses. YMMV.*
192
+
193
+ ## Notes
194
+
195
+ - Compression happens in-memory (entire response buffered)
196
+ - Already-compressed content (images, video) is skipped automatically
197
+ - `Content-Length` header is removed (server recalculates)
198
+ - `Vary: Accept-Encoding` header ensures proper caching
199
+ - Thread-safe (no shared state)
200
+
201
+ ## Troubleshooting
202
+
203
+ **Compression not working?**
204
+ - Check `Accept-Encoding` header includes `gzip`
205
+ - Verify response is > `min_size`
206
+ - Confirm `content-type` is compressible
207
+ - Check if compression actually saves space
208
+
209
+ **High CPU usage?**
210
+ - Lower compression level: `level: Zlib::BEST_SPEED`
211
+ - Increase minimum size: `min_size: 2048`
212
+ - Profile with different levels
213
+
214
+ **Wrong compressed output?**
215
+ - Ensure no plugins modify body after compression
216
+ - Verify no double-compression (CDN + origin)
@@ -0,0 +1,30 @@
1
+ # Cookies
2
+
3
+ Reading and writing cookies needs no plugin since 1.5 — the helpers are on every request and response. (`use: [:cookies]` still works; it is a no-op kept for existing apps.)
4
+
5
+ ## Reading
6
+
7
+ ```ruby
8
+ request.cookies # => { 'theme' => 'dark', ... } parsed from the Cookie header
9
+ request.cookies['theme']
10
+ ```
11
+
12
+ ## Writing
13
+
14
+ ```ruby
15
+ response.set_cookie('theme', 'dark')
16
+ response.set_cookie('remember', token, max_age: 30 * 24 * 3600, httponly: true, secure: true, same_site: :lax)
17
+ response.delete_cookie('remember')
18
+ ```
19
+
20
+ Options: `path`, `domain`, `max_age` (seconds), `expires` (a `Time`), `httponly`, `secure`, `same_site` (`:lax`, `:strict`, `:none`). Anything you do not pass comes from `Aris::Config.cookie_options`:
21
+
22
+ ```ruby
23
+ Aris.configure do |c|
24
+ c.cookie_options = { httponly: true, secure: true, same_site: :lax, path: '/' }
25
+ end
26
+ ```
27
+
28
+ ## Rack 3 headers
29
+
30
+ Cookies are emitted under the lowercase `set-cookie` header. Several cookies in one response become an **Array** of values, which is what Rack 3 requires — a comma-joined string (what 1.4 produced) is one malformed cookie to a browser. The Rack adapter also lowercases every other response header name for the same reason.