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,291 @@
1
+ # JSON Body Parser Plugin
2
+
3
+ Automatically parse JSON request bodies and attach parsed data to the request object.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ # lib/aris.rb already includes this
9
+ require_relative 'aris/plugins/json'
10
+ ```
11
+
12
+ ## How It Works
13
+
14
+ Runs on POST/PUT/PATCH requests. Reads `rack.input`, parses JSON, and attaches to `request.json_body`. Returns **400 Bad Request** on invalid JSON.
15
+
16
+ ---
17
+
18
+ ## Basic Usage
19
+
20
+ ```ruby
21
+ Aris.routes({
22
+ "api.example.com": {
23
+ use: [:json],
24
+ "/users": { post: { to: CreateUserHandler } }
25
+ }
26
+ })
27
+ ```
28
+
29
+ **Handler access:**
30
+
31
+ ```ruby
32
+ class CreateUserHandler
33
+ def self.call(request, params)
34
+ data = request.json_body
35
+
36
+ User.create(
37
+ name: data['name'],
38
+ email: data['email']
39
+ )
40
+
41
+ { success: true, user_id: user.id }
42
+ end
43
+ end
44
+ ```
45
+
46
+ **Request:**
47
+ ```bash
48
+ curl -X POST https://api.example.com/users \
49
+ -H "content-type: application/json" \
50
+ -d '{"name": "Alice", "email": "alice@example.com"}'
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Error Handling
56
+
57
+ **Invalid JSON returns 400:**
58
+
59
+ ```bash
60
+ curl -X POST https://api.example.com/users \
61
+ -H "content-type: application/json" \
62
+ -d '{invalid json}'
63
+ ```
64
+
65
+ **Response:**
66
+ ```json
67
+ {
68
+ "error": "Invalid JSON",
69
+ "message": "unexpected token at '{invalid json}'"
70
+ }
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Behavior
76
+
77
+ | Request Method | Action |
78
+ |:---|:---|
79
+ | POST, PUT, PATCH | Parse JSON body |
80
+ | GET, DELETE, HEAD | Skip (no action) |
81
+ | Empty body | Skip (no action) |
82
+ | Invalid JSON | Halt with 400 error |
83
+
84
+ **Parsed data available at:**
85
+ ```ruby
86
+ request.json_body # Hash or Array
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Common Patterns
92
+
93
+ ### Combine with Validation
94
+
95
+ ```ruby
96
+ class CreateUserHandler
97
+ def self.call(request, params)
98
+ data = request.json_body
99
+
100
+ # Validate required fields
101
+ unless data['name'] && data['email']
102
+ return [400, {}, [JSON.generate({ error: 'Missing required fields' })]]
103
+ end
104
+
105
+ User.create(data)
106
+ end
107
+ end
108
+ ```
109
+
110
+ ### Nested JSON
111
+
112
+ ```ruby
113
+ # Request body
114
+ {
115
+ "user": {
116
+ "name": "Alice",
117
+ "address": {
118
+ "city": "New York",
119
+ "zip": "10001"
120
+ }
121
+ }
122
+ }
123
+
124
+ # Handler
125
+ data = request.json_body
126
+ name = data['user']['name']
127
+ city = data['user']['address']['city']
128
+ ```
129
+
130
+ ### JSON Arrays
131
+
132
+ ```ruby
133
+ # Request: [{"name": "Alice"}, {"name": "Bob"}]
134
+
135
+ data = request.json_body # Array
136
+ data.each do |user|
137
+ User.create(name: user['name'])
138
+ end
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Plugin Order
144
+
145
+ Place JSON parser **early** in the pipeline:
146
+
147
+ ```ruby
148
+ Aris.routes({
149
+ "api.example.com": {
150
+ use: [:json, :csrf, bearer_auth], # Parse first
151
+ "/users": { post: { to: CreateUserHandler } }
152
+ }
153
+ })
154
+ ```
155
+
156
+ **Why?** Subsequent plugins or handlers may need access to `request.json_body`.
157
+
158
+ ---
159
+
160
+ ## Testing
161
+
162
+ ```ruby
163
+ class JsonParserTest < Minitest::Test
164
+ def test_valid_json_parsed
165
+ Aris.routes({
166
+ "api.test": {
167
+ use: [:json],
168
+ "/data": { post: { to: DataHandler } }
169
+ }
170
+ })
171
+
172
+ app = Aris::Adapters::RackApp.new
173
+ body = JSON.generate({ name: 'Alice' })
174
+
175
+ env = {
176
+ 'REQUEST_METHOD' => 'POST',
177
+ 'PATH_INFO' => '/data',
178
+ 'HTTP_HOST' => 'api.test',
179
+ 'rack.input' => StringIO.new(body)
180
+ }
181
+
182
+ status, _, response = app.call(env)
183
+ assert_equal 200, status
184
+ end
185
+
186
+ def test_invalid_json_returns_400
187
+ # Same setup...
188
+ env['rack.input'] = StringIO.new('{invalid}')
189
+
190
+ status, _, response = app.call(env)
191
+ assert_equal 400, status
192
+
193
+ error = JSON.parse(response.first)
194
+ assert_equal 'Invalid JSON', error['error']
195
+ end
196
+ end
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Production Tips
202
+
203
+ **1. content-type Validation**
204
+
205
+ Currently parses regardless of content-type. For strict APIs:
206
+
207
+ ```ruby
208
+ class StrictJsonParser
209
+ def self.call(request, response)
210
+ return nil unless ['POST', 'PUT', 'PATCH'].include?(request.method)
211
+
212
+ # Require correct content-type
213
+ content_type = request.headers['CONTENT_TYPE']
214
+ unless content_type&.include?('application/json')
215
+ response.status = 415
216
+ response.body = ['Unsupported Media Type']
217
+ return response
218
+ end
219
+
220
+ # Parse JSON...
221
+ end
222
+ end
223
+ ```
224
+
225
+ **2. Size Limits**
226
+
227
+ Protect against large payloads:
228
+
229
+ ```ruby
230
+ MAX_BODY_SIZE = 1_000_000 # 1MB
231
+
232
+ def call(request, response)
233
+ raw_body = request.body
234
+
235
+ if raw_body.bytesize > MAX_BODY_SIZE
236
+ response.status = 413
237
+ response.body = ['Payload Too Large']
238
+ return response
239
+ end
240
+
241
+ # Parse JSON...
242
+ end
243
+ ```
244
+
245
+ **3. Schema Validation**
246
+
247
+ Use JSON Schema for validation:
248
+
249
+ ```ruby
250
+ require 'json-schema'
251
+
252
+ class ValidatedJsonParser
253
+ SCHEMA = {
254
+ "type" => "object",
255
+ "required" => ["name", "email"],
256
+ "properties" => {
257
+ "name" => { "type" => "string" },
258
+ "email" => { "type" => "string", "format" => "email" }
259
+ }
260
+ }
261
+
262
+ def self.call(request, response)
263
+ # Parse JSON first...
264
+ data = JSON.parse(request.body)
265
+
266
+ # Validate against schema
267
+ errors = JSON::Validator.fully_validate(SCHEMA, data)
268
+ if errors.any?
269
+ response.status = 422
270
+ response.body = [JSON.generate({ errors: errors })]
271
+ return response
272
+ end
273
+
274
+ request.json_body = data
275
+ nil
276
+ end
277
+ end
278
+ ```
279
+
280
+ ---
281
+
282
+ ## Notes
283
+
284
+ - **Only parses POST/PUT/PATCH** - GET requests ignored
285
+ - **Empty bodies skipped** - No error, just continues
286
+ - **Thread-safe** - Each request has isolated `json_body`
287
+ - **No streaming** - Reads entire body into memory
288
+
289
+ ---
290
+
291
+ Need help? Check out the [full plugin development guide](../docs/plugin-development.md).
@@ -0,0 +1,427 @@
1
+ # Multipart Parser Plugin (File Uploads)
2
+
3
+ Parses `multipart/form-data` requests for file uploads and form submissions. Essential for handling file uploads in web applications.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ require 'aris/plugins/multipart'
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```ruby
14
+ multipart = Aris::Plugins::Multipart.build
15
+
16
+ Aris.routes({
17
+ "api.example.com": {
18
+ use: [multipart],
19
+ "/upload": { post: { to: UploadHandler } }
20
+ }
21
+ })
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ | Option | Type | Default | Description |
27
+ |--------|------|---------|-------------|
28
+ | `max_file_size` | Integer | `10485760` (10MB) | Maximum file size in bytes |
29
+ | `max_files` | Integer | `10` | Maximum number of files per request |
30
+ | `allowed_extensions` | Array | `nil` (all allowed) | Whitelist of allowed file extensions (e.g., `['.jpg', '.png']`) |
31
+
32
+ ## Examples
33
+
34
+ ### Basic File Upload
35
+
36
+ ```ruby
37
+ class UploadHandler
38
+ def self.call(request, params)
39
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
40
+
41
+ files = data.select { |p| p[:type] == :file }
42
+ file = files.first
43
+
44
+ {
45
+ message: "Uploaded #{file[:filename]}",
46
+ size: file[:data].bytesize,
47
+ type: file[:content_type]
48
+ }
49
+ end
50
+ end
51
+
52
+ multipart = Aris::Plugins::Multipart.build
53
+
54
+ Aris.routes({
55
+ "api.example.com": {
56
+ use: [multipart],
57
+ "/upload": { post: { to: UploadHandler } }
58
+ }
59
+ })
60
+ ```
61
+
62
+ ### Custom File Size Limit
63
+
64
+ ```ruby
65
+ multipart = Aris::Plugins::Multipart.build(
66
+ max_file_size: 5_242_880 # 5MB
67
+ )
68
+ ```
69
+
70
+ ### Restrict File Types
71
+
72
+ ```ruby
73
+ multipart = Aris::Plugins::Multipart.build(
74
+ allowed_extensions: ['.jpg', '.jpeg', '.png', '.gif']
75
+ )
76
+
77
+ # Only image files allowed
78
+ ```
79
+
80
+ ### Multiple Files with Limits
81
+
82
+ ```ruby
83
+ multipart = Aris::Plugins::Multipart.build(
84
+ max_files: 5,
85
+ max_file_size: 2_097_152 # 2MB per file
86
+ )
87
+ ```
88
+
89
+ ### Processing Uploaded Files
90
+
91
+ ```ruby
92
+ class ImageUploadHandler
93
+ def self.call(request, params)
94
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
95
+
96
+ # Get all uploaded files
97
+ files = data.select { |p| p[:type] == :file }
98
+
99
+ # Get form fields
100
+ fields = data.select { |p| p[:type] == :field }
101
+ title = fields.find { |f| f[:name] == 'title' }&.dig(:data)
102
+
103
+ # Process each file
104
+ uploaded_files = files.map do |file|
105
+ # Save to S3, disk, etc.
106
+ save_file(file[:filename], file[:data])
107
+
108
+ {
109
+ name: file[:filename],
110
+ size: file[:data].bytesize,
111
+ content_type: file[:content_type]
112
+ }
113
+ end
114
+
115
+ { title: title, files: uploaded_files }
116
+ end
117
+
118
+ def self.save_file(filename, data)
119
+ # Save to disk, S3, etc.
120
+ File.write("/tmp/uploads/#{filename}", data)
121
+ end
122
+ end
123
+ ```
124
+
125
+ ### Mixed Form Data and Files
126
+
127
+ ```ruby
128
+ class FormHandler
129
+ def self.call(request, params)
130
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
131
+
132
+ # Extract files
133
+ files = data.select { |p| p[:type] == :file }
134
+ avatar = files.find { |f| f[:name] == 'avatar' }
135
+
136
+ # Extract form fields
137
+ fields = data.select { |p| p[:type] == :field }
138
+ username = fields.find { |f| f[:name] == 'username' }&.dig(:data)
139
+ email = fields.find { |f| f[:name] == 'email' }&.dig(:data)
140
+
141
+ {
142
+ user: { username: username, email: email },
143
+ avatar: avatar ? { filename: avatar[:filename], size: avatar[:data].bytesize } : nil
144
+ }
145
+ end
146
+ end
147
+ ```
148
+
149
+ ## Parsed Data Structure
150
+
151
+ Multipart data is attached to request as `@multipart_data` array:
152
+
153
+ ```ruby
154
+ [
155
+ {
156
+ name: 'avatar', # Form field name
157
+ filename: 'photo.jpg', # Original filename
158
+ content_type: 'image/jpeg', # MIME type
159
+ data: '...', # Binary file data
160
+ type: :file # :file or :field
161
+ },
162
+ {
163
+ name: 'username',
164
+ data: 'alice',
165
+ type: :field
166
+ }
167
+ ]
168
+ ```
169
+
170
+ ## Production Tips
171
+
172
+ ### 1. File Size Limits by Type
173
+
174
+ ```ruby
175
+ # Images
176
+ image_multipart = Aris::Plugins::Multipart.build(
177
+ max_file_size: 5_242_880, # 5MB
178
+ allowed_extensions: ['.jpg', '.png', '.gif']
179
+ )
180
+
181
+ # Documents
182
+ doc_multipart = Aris::Plugins::Multipart.build(
183
+ max_file_size: 10_485_760, # 10MB
184
+ allowed_extensions: ['.pdf', '.doc', '.docx']
185
+ )
186
+
187
+ Aris.routes({
188
+ "api.example.com": {
189
+ "/upload/image": { use: [image_multipart], post: { to: ImageHandler } },
190
+ "/upload/document": { use: [doc_multipart], post: { to: DocHandler } }
191
+ }
192
+ })
193
+ ```
194
+
195
+ ### 2. Save to Disk (Production)
196
+
197
+ Don't keep files in memory:
198
+
199
+ ```ruby
200
+ class ProductionUploadHandler
201
+ def self.call(request, params)
202
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
203
+ files = data.select { |p| p[:type] == :file }
204
+
205
+ saved_files = files.map do |file|
206
+ # Generate unique filename
207
+ ext = File.extname(file[:filename])
208
+ unique_name = "#{SecureRandom.uuid}#{ext}"
209
+ path = "/var/uploads/#{unique_name}"
210
+
211
+ # Write to disk
212
+ File.binwrite(path, file[:data])
213
+
214
+ {
215
+ original_name: file[:filename],
216
+ stored_path: path,
217
+ size: file[:data].bytesize
218
+ }
219
+ end
220
+
221
+ { files: saved_files }
222
+ end
223
+ end
224
+ ```
225
+
226
+ ### 3. Upload to S3
227
+
228
+ ```ruby
229
+ require 'aws-sdk-s3'
230
+
231
+ class S3UploadHandler
232
+ S3_CLIENT = Aws::S3::Client.new(region: 'us-east-1')
233
+ BUCKET = 'my-uploads-bucket'
234
+
235
+ def self.call(request, params)
236
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
237
+ files = data.select { |p| p[:type] == :file }
238
+
239
+ uploaded_files = files.map do |file|
240
+ key = "uploads/#{SecureRandom.uuid}/#{file[:filename]}"
241
+
242
+ S3_CLIENT.put_object(
243
+ bucket: BUCKET,
244
+ key: key,
245
+ body: file[:data],
246
+ content_type: file[:content_type]
247
+ )
248
+
249
+ {
250
+ filename: file[:filename],
251
+ url: "https://#{BUCKET}.s3.amazonaws.com/#{key}",
252
+ size: file[:data].bytesize
253
+ }
254
+ end
255
+
256
+ { files: uploaded_files }
257
+ end
258
+ end
259
+ ```
260
+
261
+ ### 4. Validate Content Type
262
+
263
+ Don't trust filename extensions:
264
+
265
+ ```ruby
266
+ class SecureUploadHandler
267
+ def self.call(request, params)
268
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
269
+ files = data.select { |p| p[:type] == :file }
270
+
271
+ files.each do |file|
272
+ # Check magic bytes (first few bytes)
273
+ magic = file[:data][0..3]
274
+
275
+ case magic
276
+ when "\xFF\xD8\xFF" # JPEG
277
+ # Valid
278
+ when "\x89PNG" # PNG
279
+ # Valid
280
+ else
281
+ return { error: "Invalid file type for #{file[:filename]}" }
282
+ end
283
+ end
284
+
285
+ # Process files...
286
+ end
287
+ end
288
+ ```
289
+
290
+ ### 5. Progress Tracking (Large Files)
291
+
292
+ For very large files, consider:
293
+ - Direct-to-S3 uploads (presigned URLs)
294
+ - Chunked uploads
295
+ - Background job processing
296
+
297
+ ```ruby
298
+ # Use presigned URLs instead
299
+ class PresignedUploadHandler
300
+ def self.call(request, params)
301
+ S3_CLIENT = Aws::S3::Client.new(region: 'us-east-1')
302
+
303
+ # Generate presigned URL for client to upload directly
304
+ presigned_url = S3_CLIENT.presigned_url(
305
+ :put_object,
306
+ bucket: 'my-bucket',
307
+ key: "uploads/#{SecureRandom.uuid}/file",
308
+ expires_in: 3600
309
+ )
310
+
311
+ { upload_url: presigned_url }
312
+ end
313
+ end
314
+ ```
315
+
316
+ ### 6. Virus Scanning
317
+
318
+ ```ruby
319
+ class ScannedUploadHandler
320
+ def self.call(request, params)
321
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
322
+ files = data.select { |p| p[:type] == :file }
323
+
324
+ files.each do |file|
325
+ # Save temporarily
326
+ temp_path = "/tmp/#{SecureRandom.uuid}"
327
+ File.binwrite(temp_path, file[:data])
328
+
329
+ # Scan with ClamAV or similar
330
+ scan_result = `clamscan #{temp_path}`
331
+
332
+ if scan_result.include?('FOUND')
333
+ File.delete(temp_path)
334
+ return { error: "Virus detected in #{file[:filename]}" }
335
+ end
336
+
337
+ File.delete(temp_path)
338
+ end
339
+
340
+ # Files are clean, process them...
341
+ end
342
+ end
343
+ ```
344
+
345
+ ## Common Patterns
346
+
347
+ ### Image Thumbnails
348
+
349
+ ```ruby
350
+ require 'mini_magick'
351
+
352
+ class ThumbnailHandler
353
+ def self.call(request, params)
354
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
355
+ files = data.select { |p| p[:type] == :file }
356
+
357
+ thumbnails = files.map do |file|
358
+ # Create thumbnail
359
+ image = MiniMagick::Image.read(file[:data])
360
+ image.resize '200x200'
361
+
362
+ # Save original and thumbnail
363
+ {
364
+ original: save_file(file[:filename], file[:data]),
365
+ thumbnail: save_file("thumb_#{file[:filename]}", image.to_blob)
366
+ }
367
+ end
368
+
369
+ { thumbnails: thumbnails }
370
+ end
371
+ end
372
+ ```
373
+
374
+ ### CSV Import
375
+
376
+ ```ruby
377
+ require 'csv'
378
+
379
+ class CSVUploadHandler
380
+ def self.call(request, params)
381
+ data = request.multipart_data # every part; or request.multipart_files / request.multipart_params
382
+ files = data.select { |p| p[:type] == :file }
383
+
384
+ csv_file = files.find { |f| f[:filename].end_with?('.csv') }
385
+ return { error: 'No CSV file' } unless csv_file
386
+
387
+ # Parse CSV
388
+ rows = CSV.parse(csv_file[:data], headers: true)
389
+
390
+ {
391
+ row_count: rows.size,
392
+ headers: rows.headers,
393
+ sample: rows.first(5).map(&:to_h)
394
+ }
395
+ end
396
+ end
397
+ ```
398
+
399
+ ## Notes
400
+
401
+ - Files stored in memory (not suitable for very large files)
402
+ - For production, save to disk/S3 immediately
403
+ - Parser handles standard multipart/form-data format
404
+ - Validates file size and count before processing
405
+ - Thread-safe (no shared state)
406
+ - Only processes POST/PUT/PATCH requests
407
+
408
+ ## Troubleshooting
409
+
410
+ **Files not parsing?**
411
+ - Check content-type includes `multipart/form-data`
412
+ - Verify boundary is present in content-type header
413
+ - Ensure request method is POST/PUT/PATCH
414
+
415
+ **File size errors?**
416
+ - Check actual file size vs `max_file_size`
417
+ - Remember limit is in bytes (10MB = 10_485_760)
418
+
419
+ **Extension validation failing?**
420
+ - Extensions must include the dot: `'.jpg'` not `'jpg'`
421
+ - Comparison is case-insensitive
422
+
423
+ **Memory issues?**
424
+ - Don't use for large files (>10MB) in production
425
+ - Save to disk/S3 immediately after parsing
426
+ - Consider direct-to-S3 uploads for large files
427
+ ```