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,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).
|