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,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
|
+
```
|