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,308 @@
1
+ # ETag / Conditional Requests Plugin
2
+
3
+ Implements HTTP ETags for efficient caching with 304 Not Modified responses. Reduces bandwidth and improves performance for unchanged resources.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ require 'aris/plugins/etag'
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```ruby
14
+ etag = Aris::Plugins::ETag.build
15
+
16
+ Aris.routes({
17
+ "api.example.com": {
18
+ use: [etag], # Apply to all routes
19
+ "/users": { get: { to: UsersHandler } }
20
+ }
21
+ })
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ | Option | Type | Default | Description |
27
+ |--------|------|---------|-------------|
28
+ | `cache_control` | String | `'max-age=0, private, must-revalidate'` | Cache-Control header value |
29
+ | `strong` | Boolean | `true` | Use strong ETags (`"abc123"`) vs weak (`W/"abc123"`) |
30
+
31
+ ## How It Works
32
+
33
+ 1. **First request**: Generate MD5 hash of response body, return as ETag header
34
+ 2. **Subsequent requests**: Client sends `If-None-Match: "hash"`
35
+ 3. **If match**: Return `304 Not Modified` with empty body (saves bandwidth)
36
+ 4. **If no match**: Return `200 OK` with full body and new ETag
37
+
38
+ ## Examples
39
+
40
+ ### Default Configuration
41
+
42
+ ```ruby
43
+ etag = Aris::Plugins::ETag.build
44
+
45
+ # First request:
46
+ # Response: 200 OK
47
+ # ETag: "5d41402abc4b2a76b9719d911017c592"
48
+ # Body: "Hello World"
49
+
50
+ # Second request with If-None-Match: "5d41402abc4b2a76b9719d911017c592"
51
+ # Response: 304 Not Modified
52
+ # ETag: "5d41402abc4b2a76b9719d911017c592"
53
+ # Body: (empty)
54
+ ```
55
+
56
+ ### Public Caching
57
+
58
+ ```ruby
59
+ etag = Aris::Plugins::ETag.build(
60
+ cache_control: 'public, max-age=3600' # 1 hour cache
61
+ )
62
+
63
+ # CDNs and browsers can cache for 1 hour
64
+ ```
65
+
66
+ ### Private Caching
67
+
68
+ ```ruby
69
+ etag = Aris::Plugins::ETag.build(
70
+ cache_control: 'private, max-age=300' # 5 minute private cache
71
+ )
72
+
73
+ # Only browser caches, not CDNs
74
+ ```
75
+
76
+ ### Weak ETags
77
+
78
+ ```ruby
79
+ etag = Aris::Plugins::ETag.build(
80
+ strong: false # Use weak ETags
81
+ )
82
+
83
+ # Response: ETag: W/"5d41402abc4b2a76b9719d911017c592"
84
+ # Weak ETags allow byte-for-byte differences (compression, whitespace)
85
+ ```
86
+
87
+ ### No Caching
88
+
89
+ ```ruby
90
+ etag = Aris::Plugins::ETag.build(
91
+ cache_control: 'no-cache, no-store, must-revalidate'
92
+ )
93
+
94
+ # ETags still generated but cache discouraged
95
+ ```
96
+
97
+ ### Per-Route Configuration
98
+
99
+ ```ruby
100
+ public_etag = Aris::Plugins::ETag.build(
101
+ cache_control: 'public, max-age=86400' # 24 hours
102
+ )
103
+
104
+ private_etag = Aris::Plugins::ETag.build(
105
+ cache_control: 'private, max-age=300' # 5 minutes
106
+ )
107
+
108
+ Aris.routes({
109
+ "api.example.com": {
110
+ "/public/data": {
111
+ use: [public_etag],
112
+ get: { to: PublicDataHandler }
113
+ },
114
+ "/user/profile": {
115
+ use: [private_etag],
116
+ get: { to: ProfileHandler }
117
+ }
118
+ }
119
+ })
120
+ ```
121
+
122
+ ## Strong vs Weak ETags
123
+
124
+ **Strong ETags** (`"abc123"`):
125
+ - Byte-for-byte identical content
126
+ - Use for: APIs, exact data matching
127
+ - Default behavior
128
+
129
+ **Weak ETags** (`W/"abc123"`):
130
+ - Semantically equivalent content
131
+ - Allows minor differences (compression, formatting)
132
+ - Use for: HTML pages, compressed responses
133
+
134
+ ```ruby
135
+ # Strong (default)
136
+ etag = Aris::Plugins::ETag.build(strong: true)
137
+
138
+ # Weak
139
+ etag = Aris::Plugins::ETag.build(strong: false)
140
+ ```
141
+
142
+ ## Production Tips
143
+
144
+ ### 1. Cache-Control Strategy
145
+
146
+ **Static assets (images, JS, CSS):**
147
+ ```ruby
148
+ etag = Aris::Plugins::ETag.build(
149
+ cache_control: 'public, max-age=31536000, immutable' # 1 year
150
+ )
151
+ ```
152
+
153
+ **API responses (frequently changing):**
154
+ ```ruby
155
+ etag = Aris::Plugins::ETag.build(
156
+ cache_control: 'private, max-age=60' # 1 minute
157
+ )
158
+ ```
159
+
160
+ **User-specific data:**
161
+ ```ruby
162
+ etag = Aris::Plugins::ETag.build(
163
+ cache_control: 'private, max-age=300' # 5 minutes
164
+ )
165
+ ```
166
+
167
+ **Real-time data:**
168
+ ```ruby
169
+ etag = Aris::Plugins::ETag.build(
170
+ cache_control: 'no-cache, must-revalidate' # Validate every time
171
+ )
172
+ ```
173
+
174
+ ### 2. Plugin Ordering
175
+
176
+ Place **after** compression, **before** logging:
177
+
178
+ ```ruby
179
+ Aris.routes({
180
+ "api.example.com": {
181
+ use: [
182
+ bearer_auth, # Authenticate
183
+ compression, # Compress response
184
+ etag, # ← Generate ETag (from compressed body)
185
+ request_logger # Log (sees 304s)
186
+ ]
187
+ }
188
+ })
189
+ ```
190
+
191
+ ### 3. CDN Compatibility
192
+
193
+ Most CDNs respect ETags:
194
+ ```ruby
195
+ # Origin generates ETags
196
+ etag = Aris::Plugins::ETag.build(
197
+ cache_control: 'public, max-age=3600'
198
+ )
199
+
200
+ # CDN will:
201
+ # 1. Cache response with ETag
202
+ # 2. Validate with origin using If-None-Match
203
+ # 3. Serve from cache on 304
204
+ ```
205
+
206
+ ### 4. Database-Backed ETags
207
+
208
+ For better cache invalidation, use timestamps:
209
+
210
+ ```ruby
211
+ class UserHandler
212
+ def self.call(request, params)
213
+ user = User.find(params[:id])
214
+
215
+ # Set custom ETag from updated_at timestamp
216
+ response = Aris::Adapters::Rack::Response.new
217
+ response.headers['ETag'] = %("#{user.updated_at.to_i}")
218
+ response.body = [user.to_json]
219
+ response
220
+ end
221
+ end
222
+ ```
223
+
224
+ Plugin will respect existing ETags and skip generation.
225
+
226
+ ### 5. Monitoring 304 Responses
227
+
228
+ Track cache hit rate:
229
+ ```ruby
230
+ # In logs, count 304 vs 200 responses
231
+ # High 304 rate = effective caching
232
+ # Target: 60-80% 304 rate for cacheable endpoints
233
+ ```
234
+
235
+ ## Benchmarks
236
+
237
+ **304 Response Savings:**
238
+ - Bandwidth: 95-99% reduction (only headers sent)
239
+ - Server CPU: 0% (handler not executed)
240
+ - Response time: 10-50ms vs 100-500ms for full response
241
+
242
+ **Example:**
243
+ - Full response: 50KB, 100ms
244
+ - 304 response: 500 bytes, 10ms
245
+ - 100 requests: 5MB → 50KB saved (99% reduction)
246
+
247
+ ## Notes
248
+
249
+ - ETags generated using MD5 hash of response body
250
+ - Only applies to successful GET/HEAD requests (200 status)
251
+ - Does not override existing ETag headers
252
+ - Does not override existing Cache-Control headers
253
+ - Thread-safe (no shared state)
254
+ - Works with compressed responses (generates ETag from compressed body)
255
+
256
+ ## Common Patterns
257
+
258
+ ### Combining with CORS
259
+
260
+ ```ruby
261
+ cors = Aris::Plugins::Cors.build(origins: '*')
262
+ etag = Aris::Plugins::ETag.build(cache_control: 'public, max-age=600')
263
+
264
+ Aris.routes({
265
+ "api.example.com": {
266
+ use: [cors, etag], # CORS first, then ETag
267
+ "/data": { get: { to: DataHandler } }
268
+ }
269
+ })
270
+ ```
271
+
272
+ ### API Versioning
273
+
274
+ ```ruby
275
+ v1_etag = Aris::Plugins::ETag.build(cache_control: 'private, max-age=300')
276
+ v2_etag = Aris::Plugins::ETag.build(cache_control: 'private, max-age=600')
277
+
278
+ Aris.routes({
279
+ "api.example.com": {
280
+ "/v1": {
281
+ use: [v1_etag],
282
+ "/users": { get: { to: V1::UsersHandler } }
283
+ },
284
+ "/v2": {
285
+ use: [v2_etag],
286
+ "/users": { get: { to: V2::UsersHandler } }
287
+ }
288
+ }
289
+ })
290
+ ```
291
+
292
+ ## Troubleshooting
293
+
294
+ **ETags not working?**
295
+ - Verify GET request (ETags only for GET/HEAD)
296
+ - Check response status is 200
297
+ - Confirm client sends `If-None-Match` header
298
+
299
+ **Always getting 200, never 304?**
300
+ - Response body is changing (dynamic content)
301
+ - Check for timestamps or random data in response
302
+ - Use database-backed ETags for dynamic content
303
+
304
+ **CDN not caching?**
305
+ - Check `Cache-Control` includes `public`
306
+ - Verify `max-age` is set
307
+ - Add `Vary: Accept-Encoding` if using compression
308
+ ```
@@ -0,0 +1,193 @@
1
+ # Form Parser Plugin
2
+
3
+ Parse URL-encoded form data from HTML forms.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ require_relative 'aris/plugins/form_parser'
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```ruby
14
+ form = Aris::Plugins::FormParser.build
15
+
16
+ Aris.routes({
17
+ "example.com": {
18
+ use: [form],
19
+ "/submit": { post: { to: FormHandler } }
20
+ }
21
+ })
22
+ ```
23
+
24
+ **HTML Form:**
25
+ ```html
26
+ <form action="/submit" method="POST">
27
+ <input name="username" value="alice">
28
+ <input name="email" value="alice@example.com">
29
+ <button type="submit">Submit</button>
30
+ </form>
31
+ ```
32
+
33
+ **Handler:**
34
+ ```ruby
35
+ class FormHandler
36
+ def self.call(request, params)
37
+ data = request.form_params
38
+
39
+ username = data['username'] #=> "alice"
40
+ email = data['email'] #=> "alice@example.com"
41
+
42
+ "Welcome, #{username}!"
43
+ end
44
+ end
45
+ ```
46
+
47
+ ---
48
+
49
+ ## How It Works
50
+
51
+ Parses `application/x-www-form-urlencoded` bodies on POST/PUT/PATCH requests and exposes the fields as `request.form_params`, and merges them into `request.params` (query string + form fields).
52
+
53
+ **Parsed formats:**
54
+ - Simple: `name=value&email=test@example.com`
55
+ - Nested: `user[name]=alice&user[email]=alice@example.com`
56
+ - Arrays: `tags[]=ruby&tags[]=rails`
57
+
58
+ ---
59
+
60
+ ## Common Patterns
61
+
62
+ ### Nested Parameters
63
+
64
+ ```html
65
+ <form action="/users" method="POST">
66
+ <input name="user[name]" value="Alice">
67
+ <input name="user[email]" value="alice@example.com">
68
+ <input name="user[age]" value="30">
69
+ </form>
70
+ ```
71
+
72
+ ```ruby
73
+ data = request.form_params
74
+ # {"user" => {"name" => "Alice", "email" => "alice@example.com", "age" => "30"}}
75
+
76
+ user_data = data['user']
77
+ name = user_data['name'] #=> "Alice"
78
+ ```
79
+
80
+ ### Arrays
81
+
82
+ ```html
83
+ <form action="/posts" method="POST">
84
+ <input name="tags[]" value="ruby">
85
+ <input name="tags[]" value="rails">
86
+ <input name="tags[]" value="web">
87
+ </form>
88
+ ```
89
+
90
+ ```ruby
91
+ data = request.form_params
92
+ # {"tags" => ["ruby", "rails", "web"]}
93
+
94
+ tags = data['tags'] #=> ["ruby", "rails", "web"]
95
+ ```
96
+
97
+ ### Checkboxes
98
+
99
+ ```html
100
+ <form action="/settings" method="POST">
101
+ <input type="checkbox" name="features[]" value="dark_mode" checked>
102
+ <input type="checkbox" name="features[]" value="notifications" checked>
103
+ </form>
104
+ ```
105
+
106
+ ```ruby
107
+ features = data['features'] #=> ["dark_mode", "notifications"]
108
+ ```
109
+
110
+ ### Validation
111
+
112
+ ```ruby
113
+ class FormHandler
114
+ def self.call(request, params)
115
+ data = request.form_params
116
+
117
+ unless data && data['email']
118
+ return [400, {}, ['Email required']]
119
+ end
120
+
121
+ # Process form...
122
+ end
123
+ end
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Behavior
129
+
130
+ | Request Method | Action |
131
+ |:---|:---|
132
+ | POST, PUT, PATCH | Parse if content-type matches |
133
+ | GET, DELETE | Skip |
134
+ | Wrong content-type | Skip |
135
+ | Empty body | Skip |
136
+
137
+ **content-type must be:** `application/x-www-form-urlencoded`
138
+
139
+ ---
140
+
141
+ ## Production Tips
142
+
143
+ **1. Combine with CSRF Protection**
144
+
145
+ ```ruby
146
+ Aris.routes({
147
+ "example.com": {
148
+ use: [:csrf, form_parser], # CSRF validates, then parse form
149
+ "/submit": { post: { to: FormHandler } }
150
+ }
151
+ })
152
+ ```
153
+
154
+ **2. Validation**
155
+
156
+ ```ruby
157
+ class FormHandler
158
+ REQUIRED = ['name', 'email']
159
+
160
+ def self.call(request, params)
161
+ data = request.form_params
162
+
163
+ missing = REQUIRED - data.keys
164
+ return [400, {}, ["Missing: #{missing.join(', ')}"]] if missing.any?
165
+
166
+ # Process...
167
+ end
168
+ end
169
+ ```
170
+
171
+ **3. Sanitization**
172
+
173
+ ```ruby
174
+ def sanitize(data)
175
+ data.transform_values { |v| v.is_a?(String) ? v.strip : v }
176
+ end
177
+
178
+ data = request.form_params
179
+ clean_data = sanitize(data)
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Notes
185
+
186
+ - All values are strings (use `.to_i`, `.to_f` for conversion)
187
+ - Uses Rack's built-in parser (battle-tested)
188
+ - Nested arrays handled automatically
189
+ - No file upload support (use multipart parser for that)
190
+
191
+ ---
192
+
193
+ Need help? Check out the [full plugin development guide](../docs/plugin-development.md).