gr_api_manager 0.1.0 → 0.3.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/README.md +645 -163
- data/lib/gr_api_manager.rb +448 -52
- metadata +21 -4
data/README.md
CHANGED
|
@@ -1,164 +1,185 @@
|
|
|
1
1
|
# GR API Manager
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/gr-api-manager)
|
|
4
|
+
[](README_ES.md)
|
|
4
5
|
|
|
5
|
-
A minimal
|
|
6
|
+
A minimal Ruby wrapper around Sinatra that eliminates boilerplate from REST API development. Auth, parameter validation, type casting, CORS, error handling, **file uploads, raw binaries, images, Base64 and hexadecimal** — all at the framework level. You write only the business logic.
|
|
6
7
|
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
## Features
|
|
10
11
|
|
|
11
|
-
-
|
|
12
|
-
- Route-level Bearer Token
|
|
13
|
-
- Declarative required parameter validation
|
|
14
|
-
- Smart type casting
|
|
15
|
-
- Global JSON error responses for `404` and `
|
|
16
|
-
- Broad CORS and `OPTIONS`
|
|
17
|
-
- API versioning via configurable route prefix (
|
|
12
|
+
- Available on **RubyGems** — install globally, use anywhere.
|
|
13
|
+
- Route-level Bearer Token auth (on/off per endpoint).
|
|
14
|
+
- Declarative required parameter validation → automatic `400 Bad Request`.
|
|
15
|
+
- Smart type casting: `"10"` → `Integer`, `"true"` → `TrueClass`, `"9.5"` → `Float`.
|
|
16
|
+
- Global JSON error responses for `404`, `500`, and `413`.
|
|
17
|
+
- Broad CORS and preflight `OPTIONS` out of the box.
|
|
18
|
+
- API versioning via configurable route prefix (`/api/v1`, `/geo/v2`, etc.).
|
|
19
|
+
- **Full binary & file support** — multipart, raw binary, Base64, hex, `text/plain`.
|
|
20
|
+
- Configurable body size limit with `mb` / `gb` helpers.
|
|
21
|
+
- **Dev mode** — full stack traces on `500` for easier debugging.
|
|
18
22
|
|
|
19
23
|
---
|
|
20
24
|
|
|
21
25
|
## Installation
|
|
22
26
|
|
|
23
|
-
Add this line to your application's Gemfile:
|
|
24
|
-
|
|
25
27
|
```ruby
|
|
26
|
-
gem 'gr-api-manager'
|
|
28
|
+
gem 'gr-api-manager' # Gemfile
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
And then execute:
|
|
30
31
|
```bash
|
|
31
32
|
bundle install
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Or install it yourself directly via RubyGems:
|
|
35
|
-
```bash
|
|
36
|
-
gem install gr-api-manager
|
|
33
|
+
# or: gem install gr-api-manager
|
|
37
34
|
```
|
|
38
35
|
|
|
39
36
|
---
|
|
40
37
|
|
|
41
|
-
## Quick Start
|
|
38
|
+
## Quick Start — name it anything you want
|
|
39
|
+
|
|
40
|
+
The `Server` object is just a Ruby object. Call it `api`, `app`, `pais_api`, `backend`, `mi_servicio` — whatever fits your project. There is no magic name.
|
|
42
41
|
|
|
43
42
|
```ruby
|
|
44
43
|
require 'gr_api_manager'
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
# Public endpoint
|
|
53
|
-
api.get('/health', auth: false) do
|
|
54
|
-
{ status: 'online', time: Time.now.to_s }
|
|
55
|
-
end
|
|
45
|
+
# Any name works
|
|
46
|
+
api = GRApiManager::Server.new(port: 4567, bearer_token: "secret")
|
|
47
|
+
pais_api = GRApiManager::Server.new(prefix: "/geo/v1")
|
|
48
|
+
reports = GRApiManager::Server.new(port: 5000, max_body_size: GRApiManager.gb(1))
|
|
56
49
|
|
|
50
|
+
api.get('/health', auth: false) { { status: 'ok' } }
|
|
57
51
|
api.run!
|
|
58
52
|
```
|
|
59
53
|
|
|
60
|
-
```bash
|
|
61
|
-
curl http://localhost:4567/api/v1/health
|
|
62
|
-
# => {"status":"online","time":"..."}
|
|
63
|
-
```
|
|
64
|
-
|
|
65
54
|
---
|
|
66
55
|
|
|
67
56
|
## Configuration
|
|
68
57
|
|
|
69
|
-
All parameters are optional. If omitted, the manager falls back to environment variables loaded from a `.env` file at the project root (via `dotenv`). If neither is provided, defaults are used.
|
|
70
|
-
|
|
71
58
|
```ruby
|
|
72
59
|
GRApiManager::Server.new(
|
|
73
|
-
port:
|
|
74
|
-
bearer_token:
|
|
75
|
-
permitted_hosts: ["example.com"],
|
|
76
|
-
prefix:
|
|
60
|
+
port: 4567,
|
|
61
|
+
bearer_token: "secret",
|
|
62
|
+
permitted_hosts: ["example.com"], # empty = allow all
|
|
63
|
+
prefix: "/api/v1",
|
|
64
|
+
max_body_size: GRApiManager.mb(50), # default 50 MB
|
|
65
|
+
dev_mode: false # default false
|
|
77
66
|
)
|
|
78
67
|
```
|
|
79
68
|
|
|
80
|
-
###
|
|
69
|
+
### Size helpers
|
|
81
70
|
|
|
82
|
-
|
|
71
|
+
Express limits cleanly without doing manual math:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
GRApiManager.mb(50) # 50 megabytes
|
|
75
|
+
GRApiManager.mb(200) # 200 megabytes — for large file uploads
|
|
76
|
+
GRApiManager.gb(1) # 1 gigabyte — for heavy reports or video
|
|
77
|
+
|
|
78
|
+
# Examples
|
|
79
|
+
video_api = GRApiManager::Server.new(max_body_size: GRApiManager.gb(2))
|
|
80
|
+
json_api = GRApiManager::Server.new(max_body_size: GRApiManager.mb(1))
|
|
81
|
+
report_api = GRApiManager::Server.new(max_body_size: GRApiManager.mb(500))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### `.env` file (recommended for production)
|
|
83
85
|
|
|
84
86
|
```env
|
|
85
87
|
PORT=4567
|
|
86
88
|
API_TOKEN=your_secret_token
|
|
87
89
|
```
|
|
88
90
|
|
|
89
|
-
Then initialize the manager with no arguments and it will pick everything up automatically:
|
|
90
|
-
|
|
91
91
|
```ruby
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
api = GRApiManager::Server.new
|
|
95
|
-
# Reads PORT and API_TOKEN from .env
|
|
92
|
+
api = GRApiManager::Server.new # picks up PORT and API_TOKEN automatically
|
|
96
93
|
```
|
|
97
94
|
|
|
98
|
-
|
|
95
|
+
Explicit arguments always override `.env`. Add `.env` to `.gitignore`.
|
|
99
96
|
|
|
100
|
-
|
|
101
|
-
# .gitignore
|
|
102
|
-
.env
|
|
103
|
-
```
|
|
97
|
+
---
|
|
104
98
|
|
|
105
|
-
|
|
99
|
+
## HTTP Verbs — what each one does
|
|
106
100
|
|
|
107
|
-
|
|
101
|
+
| Method | Purpose | Typical use |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `GET` | **Read** — fetch data, no side effects | List, search, get by ID |
|
|
104
|
+
| `POST` | **Create** — add a new resource | Create user, upload file |
|
|
105
|
+
| `PUT` | **Full replace** — replace an existing resource entirely | Update full user profile |
|
|
106
|
+
| `PATCH` | **Partial update** — modify specific fields only | Change only an email |
|
|
107
|
+
| `DELETE` | **Remove** — delete a resource | Delete user, remove file |
|
|
108
108
|
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
```ruby
|
|
110
|
+
api.get('/users') { ... } # list
|
|
111
|
+
api.get('/users/:id') { ... } # read one
|
|
112
|
+
api.post('/users') { ... } # create
|
|
113
|
+
api.put('/users/:id') { ... } # full replace
|
|
114
|
+
api.patch('/users/:id') { ... } # partial update
|
|
115
|
+
api.delete('/users/:id') { ... } # delete
|
|
112
116
|
```
|
|
113
117
|
|
|
114
118
|
---
|
|
115
119
|
|
|
116
120
|
## Defining Routes
|
|
117
121
|
|
|
118
|
-
The manager exposes `get`, `post`, `put`, and `delete` methods. Each route receives a merged `params` hash containing both URL/query parameters (type-cast) and the parsed JSON body.
|
|
119
|
-
|
|
120
122
|
```ruby
|
|
121
|
-
api.
|
|
122
|
-
# params
|
|
123
|
-
{
|
|
124
|
-
requested_role: params[:role],
|
|
125
|
-
is_adult: params[:age] >= 18 # age is safely cast to Integer
|
|
126
|
-
}
|
|
123
|
+
api.post('/path', auth: true, requires: [:name, :email]) do |params|
|
|
124
|
+
# params — merged hash: URL segments + query string (type-cast) + parsed body
|
|
125
|
+
{ result: "ok" }
|
|
127
126
|
end
|
|
128
127
|
```
|
|
129
128
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
|
133
|
-
|
|
134
|
-
| `auth` | Boolean | `true` | Require Bearer Token for this route |
|
|
135
|
-
| `requires` | Array | `[]` | List of required parameter keys (symbols/strings)|
|
|
129
|
+
| Option | Type | Default | Description |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| `auth` | Boolean | `true` | Require Bearer Token |
|
|
132
|
+
| `requires` | Array | `[]` | Required parameter keys |
|
|
136
133
|
|
|
137
134
|
---
|
|
138
135
|
|
|
139
|
-
##
|
|
136
|
+
## Success Responses
|
|
140
137
|
|
|
141
|
-
|
|
138
|
+
Return any Hash or Array — the framework serializes to JSON automatically.
|
|
142
139
|
|
|
143
|
-
```
|
|
144
|
-
|
|
140
|
+
```ruby
|
|
141
|
+
# Simple hash
|
|
142
|
+
api.get('/ping', auth: false) { { pong: true } }
|
|
143
|
+
|
|
144
|
+
# Set a status code
|
|
145
|
+
api.post('/users', requires: [:name]) do |params|
|
|
146
|
+
status 201
|
|
147
|
+
{ message: "Created", user: { name: params[:name] } }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Short-circuit with next
|
|
151
|
+
api.get('/users/:id') do |params|
|
|
152
|
+
if params[:id] == 0
|
|
153
|
+
status 404
|
|
154
|
+
next { error: "Not found" }
|
|
155
|
+
end
|
|
156
|
+
{ id: params[:id], name: "Alice" }
|
|
157
|
+
end
|
|
145
158
|
```
|
|
146
159
|
|
|
147
|
-
|
|
160
|
+
> Returning a `String` sends it **as-is** (no JSON wrapping) — useful for binary file responses.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Authentication
|
|
148
165
|
|
|
149
166
|
```ruby
|
|
150
|
-
api.get('/health', auth: false)
|
|
151
|
-
|
|
167
|
+
api.get('/health', auth: false) { { status: 'online' } } # public
|
|
168
|
+
|
|
169
|
+
api.get('/data') do |params| # protected (default)
|
|
170
|
+
{ secret: "data" }
|
|
152
171
|
end
|
|
153
172
|
```
|
|
154
173
|
|
|
155
|
-
|
|
174
|
+
```bash
|
|
175
|
+
curl -H "Authorization: Bearer your_token" http://localhost:4567/api/v1/data
|
|
176
|
+
```
|
|
156
177
|
|
|
157
178
|
```json
|
|
158
|
-
//
|
|
179
|
+
// 401 — missing header
|
|
159
180
|
{ "error": "Token required. Format: 'Bearer <token>'" }
|
|
160
181
|
|
|
161
|
-
//
|
|
182
|
+
// 403 — wrong token
|
|
162
183
|
{ "error": "Invalid token" }
|
|
163
184
|
```
|
|
164
185
|
|
|
@@ -166,10 +187,8 @@ end
|
|
|
166
187
|
|
|
167
188
|
## Parameter Validation
|
|
168
189
|
|
|
169
|
-
Declare required parameters at the route level. If any are missing or blank, the framework responds with `400 Bad Request` automatically — no conditional logic needed in your handler.
|
|
170
|
-
|
|
171
190
|
```ruby
|
|
172
|
-
api.post('/users', requires: [:name, :email]) do |params|
|
|
191
|
+
api.post('/users', requires: [:name, :email, :role]) do |params|
|
|
173
192
|
status 201
|
|
174
193
|
{ message: "User created", user: params }
|
|
175
194
|
end
|
|
@@ -179,37 +198,219 @@ end
|
|
|
179
198
|
curl -X POST http://localhost:4567/api/v1/users \
|
|
180
199
|
-H "Authorization: Bearer secret" \
|
|
181
200
|
-H "Content-Type: application/json" \
|
|
182
|
-
-d '{"name":
|
|
201
|
+
-d '{"name":"Gabo"}'
|
|
183
202
|
|
|
184
|
-
# => 400 { "error": "Missing required parameters", "required": ["email"] }
|
|
203
|
+
# => 400 { "error": "Missing required parameters", "required": ["email", "role"] }
|
|
185
204
|
```
|
|
186
205
|
|
|
187
206
|
---
|
|
188
207
|
|
|
189
208
|
## Smart Type Casting
|
|
190
209
|
|
|
191
|
-
Query string
|
|
210
|
+
Query string values are cast to native Ruby types before reaching your block:
|
|
192
211
|
|
|
193
|
-
|
|
|
194
|
-
|
|
195
|
-
| `"42"`
|
|
196
|
-
| `"3.14"`
|
|
197
|
-
| `"true"`
|
|
198
|
-
| `"false"`
|
|
199
|
-
| `"hello"` | String | `"hello"`|
|
|
212
|
+
| String | Ruby type | Value |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| `"42"` | Integer | `42` |
|
|
215
|
+
| `"3.14"` | Float | `3.14` |
|
|
216
|
+
| `"true"` | TrueClass | `true` |
|
|
217
|
+
| `"false"` | FalseClass | `false` |
|
|
200
218
|
|
|
201
219
|
```bash
|
|
202
|
-
curl "http://localhost:4567/api/v1/
|
|
203
|
-
|
|
220
|
+
curl "http://localhost:4567/api/v1/products?page=2&active=true&price=9.99"
|
|
221
|
+
# params => { page: 2, active: true, price: 9.99 }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## File & Binary Body Handling
|
|
227
|
+
|
|
228
|
+
The framework auto-detects `Content-Type` and parses accordingly. **No extra setup needed.**
|
|
204
229
|
|
|
205
|
-
|
|
230
|
+
### Supported body formats
|
|
231
|
+
|
|
232
|
+
| Content-Type | What you get in `params` |
|
|
233
|
+
|---|---|
|
|
234
|
+
| `application/json` | Regular symbolized hash |
|
|
235
|
+
| `multipart/form-data` | Text fields + `:_files` → `{ field: FilePayload }` |
|
|
236
|
+
| `image/*`, `video/*`, `audio/*` | `:_raw_binary` → `FilePayload` |
|
|
237
|
+
| `application/pdf`, `application/msword`, `application/vnd.*` | `:_raw_binary` → `FilePayload` |
|
|
238
|
+
| `application/octet-stream` | `:_raw_binary` → `FilePayload` |
|
|
239
|
+
| `text/plain` | `:_raw_text` → `String` |
|
|
240
|
+
|
|
241
|
+
### `FilePayload` API
|
|
242
|
+
|
|
243
|
+
| Method | Returns | Description |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| `#read` | `String` (binary) | Raw bytes |
|
|
246
|
+
| `#to_base64` | `String` | Base64-encoded (no newlines) |
|
|
247
|
+
| `#to_hex` | `String` | Lowercase hexadecimal string |
|
|
248
|
+
| `#save_to(path)` | `String` | Save to disk, returns path |
|
|
249
|
+
| `#filename` | `String` | Original filename |
|
|
250
|
+
| `#content_type` | `String` | MIME type |
|
|
251
|
+
| `#size` | `Integer` | Size in bytes |
|
|
252
|
+
| `#extension` | `String` | Extension (`.jpg`, `.pdf`, etc.) |
|
|
253
|
+
| `#to_h` | `Hash` | JSON-safe summary |
|
|
254
|
+
|
|
255
|
+
### Multipart image upload
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
api.post('/upload/avatar', auth: true) do |params|
|
|
259
|
+
file = params[:_files][:avatar]
|
|
260
|
+
halt 400, { error: 'Field "avatar" required' }.to_json unless file
|
|
261
|
+
halt 400, { error: "Invalid type" }.to_json unless %w[.jpg .png .webp].include?(file.extension)
|
|
262
|
+
|
|
263
|
+
file.save_to("/uploads/#{file.filename}")
|
|
264
|
+
status 201
|
|
265
|
+
{ message: "Uploaded", file: file.to_h }
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
curl -X POST http://localhost:4567/api/v1/upload/avatar \
|
|
271
|
+
-H "Authorization: Bearer secret" \
|
|
272
|
+
-F "avatar=@photo.jpg"
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Multiple files in one request
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
api.post('/documents', auth: true) do |params|
|
|
279
|
+
files = params[:_files] || {}
|
|
280
|
+
halt 400, { error: 'No files received' }.to_json if files.empty?
|
|
281
|
+
|
|
282
|
+
saved = files.map { |_, f| f.save_to("/uploads/#{f.filename}"); f.to_h }
|
|
283
|
+
status 201
|
|
284
|
+
{ uploaded: saved }
|
|
285
|
+
end
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
curl -X POST http://localhost:4567/api/v1/documents \
|
|
290
|
+
-H "Authorization: Bearer secret" \
|
|
291
|
+
-F "doc=@report.pdf" -F "thumbnail=@thumb.png"
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Raw binary body (image, PDF, Word, etc.)
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
api.post('/files/raw', auth: true) do |params|
|
|
298
|
+
file = params[:_raw_binary]
|
|
299
|
+
halt 400, { error: 'Binary body required' }.to_json unless file
|
|
300
|
+
|
|
301
|
+
path = file.save_to("/uploads/#{file.filename}")
|
|
302
|
+
{ saved_to: path, magic_bytes: file.to_hex[0, 8], size: file.size }
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
curl -X POST http://localhost:4567/api/v1/files/raw \
|
|
308
|
+
-H "Authorization: Bearer secret" \
|
|
309
|
+
-H "Content-Type: image/png" --data-binary @photo.png
|
|
310
|
+
|
|
311
|
+
curl -X POST http://localhost:4567/api/v1/files/raw \
|
|
312
|
+
-H "Authorization: Bearer secret" \
|
|
313
|
+
-H "Content-Type: application/pdf" --data-binary @doc.pdf
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Base64 inside JSON
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
require 'base64'
|
|
320
|
+
|
|
321
|
+
api.post('/files/base64', auth: true, requires: [:data, :filename]) do |params|
|
|
322
|
+
raw = Base64.strict_decode64(params[:data])
|
|
323
|
+
File.open("/uploads/#{params[:filename]}", 'wb') { |f| f.write(raw) }
|
|
324
|
+
{ message: "Saved", bytes: raw.bytesize }
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
BASE64=$(base64 -w0 photo.jpg)
|
|
330
|
+
curl -X POST http://localhost:4567/api/v1/files/base64 \
|
|
331
|
+
-H "Authorization: Bearer secret" \
|
|
332
|
+
-H "Content-Type: application/json" \
|
|
333
|
+
-d "{\"filename\":\"photo.jpg\",\"data\":\"$BASE64\"}"
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Hexadecimal
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
api.post('/files/tohex', auth: true) do |params|
|
|
340
|
+
file = params[:_raw_binary]
|
|
341
|
+
halt 400, { error: 'Binary body required' }.to_json unless file
|
|
342
|
+
# to_hex converts any binary to a lowercase hex string
|
|
343
|
+
{ hex: file.to_hex, bytes: file.size }
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Plain text body
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
api.post('/notes', auth: true) do |params|
|
|
351
|
+
text = params[:_raw_text]
|
|
352
|
+
halt 400, { error: 'Empty body' }.to_json if text.nil? || text.strip.empty?
|
|
353
|
+
{ received: text, words: text.split.size }
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Serving a file as binary response
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
api.get('/files/:name', auth: true) do |params|
|
|
361
|
+
path = "/uploads/#{File.basename(params[:name].to_s)}"
|
|
362
|
+
halt 404, { error: "Not found" }.to_json unless File.exist?(path)
|
|
363
|
+
|
|
364
|
+
content_type 'application/octet-stream'
|
|
365
|
+
response.headers['Content-Disposition'] = "attachment; filename=\"#{File.basename(path)}\""
|
|
366
|
+
File.binread(path) # String → sent as-is, no JSON wrapping
|
|
367
|
+
end
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Body size limit
|
|
371
|
+
|
|
372
|
+
```json
|
|
373
|
+
// When exceeded → 413
|
|
374
|
+
{ "error": "Payload too large", "max_bytes": 52428800 }
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
GRApiManager::Server.new(max_body_size: GRApiManager.mb(200)) # 200 MB
|
|
379
|
+
GRApiManager::Server.new(max_body_size: GRApiManager.gb(2)) # 2 GB
|
|
380
|
+
GRApiManager::Server.new(max_body_size: GRApiManager.mb(1)) # 1 MB
|
|
206
381
|
```
|
|
207
382
|
|
|
208
383
|
---
|
|
209
384
|
|
|
210
|
-
##
|
|
385
|
+
## Dev Mode
|
|
386
|
+
|
|
387
|
+
Enable during development for full stack traces on `500` errors:
|
|
211
388
|
|
|
212
|
-
|
|
389
|
+
```ruby
|
|
390
|
+
api = GRApiManager::Server.new(
|
|
391
|
+
bearer_token: "secret",
|
|
392
|
+
dev_mode: true # ⚠️ Disable in production
|
|
393
|
+
)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
**Production (dev_mode: false):**
|
|
397
|
+
```json
|
|
398
|
+
{ "error": "Internal Server Error", "details": "undefined method 'foo' for nil" }
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**Development (dev_mode: true):**
|
|
402
|
+
```json
|
|
403
|
+
{
|
|
404
|
+
"error": "Internal Server Error",
|
|
405
|
+
"details": "undefined method 'foo' for nil",
|
|
406
|
+
"class": "NoMethodError",
|
|
407
|
+
"backtrace": ["app.rb:42:in 'block in register_route'", "..."]
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## Error Handling
|
|
213
414
|
|
|
214
415
|
```json
|
|
215
416
|
// 404
|
|
@@ -217,30 +418,19 @@ Global handlers return consistent JSON for unmatched routes and unhandled except
|
|
|
217
418
|
|
|
218
419
|
// 500
|
|
219
420
|
{ "error": "Internal Server Error", "details": "..." }
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
You can also set status codes and short-circuit responses manually inside any handler:
|
|
223
421
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if params[:id] == 0
|
|
227
|
-
status 404
|
|
228
|
-
next { error: "Invalid user ID" }
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
{ id: params[:id], name: "User_#{params[:id]}" }
|
|
232
|
-
end
|
|
422
|
+
// 413
|
|
423
|
+
{ "error": "Payload too large", "max_bytes": 52428800 }
|
|
233
424
|
```
|
|
234
425
|
|
|
235
426
|
---
|
|
236
427
|
|
|
237
428
|
## Request Logging
|
|
238
429
|
|
|
239
|
-
Every request is logged to stdout with a timestamp and color-coded status:
|
|
240
|
-
|
|
241
430
|
```text
|
|
242
|
-
[14:32:01] GET
|
|
431
|
+
[14:32:01] GET /api/v1/health - 200
|
|
243
432
|
[14:32:05] POST /api/v1/users - 400
|
|
433
|
+
[14:32:10] POST /api/v1/upload/avatar - 201
|
|
244
434
|
```
|
|
245
435
|
|
|
246
436
|
Green for 2xx, red for everything else.
|
|
@@ -249,97 +439,389 @@ Green for 2xx, red for everything else.
|
|
|
249
439
|
|
|
250
440
|
## Running the Server
|
|
251
441
|
|
|
252
|
-
```ruby
|
|
253
|
-
api.run!
|
|
254
|
-
```
|
|
255
|
-
|
|
256
442
|
```text
|
|
257
443
|
=============================================
|
|
258
444
|
GR API MANAGER STARTED
|
|
259
|
-
Port
|
|
260
|
-
Auth
|
|
261
|
-
Prefix
|
|
445
|
+
Port : 4567
|
|
446
|
+
Auth : Enabled
|
|
447
|
+
Prefix : /api/v1
|
|
448
|
+
Max Body : 50.0 MB
|
|
449
|
+
Dev Mode : Off
|
|
262
450
|
=============================================
|
|
263
451
|
```
|
|
264
452
|
|
|
265
453
|
---
|
|
266
454
|
|
|
267
|
-
##
|
|
455
|
+
## Complete Example — Country API
|
|
268
456
|
|
|
269
|
-
|
|
457
|
+
This shows how the framework handles a real-world CRUD API with file uploads. Note that the server is named `pais_api` — **you can name it anything**.
|
|
270
458
|
|
|
271
459
|
```ruby
|
|
272
460
|
require 'gr_api_manager'
|
|
461
|
+
require 'fileutils'
|
|
273
462
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
463
|
+
FileUtils.mkdir_p('/uploads/flags')
|
|
464
|
+
|
|
465
|
+
pais_api = GRApiManager::Server.new(
|
|
466
|
+
port: 4567,
|
|
467
|
+
bearer_token: "geo_secret_2024",
|
|
468
|
+
prefix: "/geo/v1",
|
|
469
|
+
max_body_size: GRApiManager.mb(5),
|
|
470
|
+
dev_mode: true
|
|
278
471
|
)
|
|
279
472
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
{
|
|
473
|
+
COUNTRIES = [
|
|
474
|
+
{ id: 1, name: "Mexico", capital: "Mexico City", pop: 128_000_000, continent: "North America" },
|
|
475
|
+
{ id: 2, name: "Argentina", capital: "Buenos Aires", pop: 45_000_000, continent: "South America" },
|
|
476
|
+
{ id: 3, name: "Spain", capital: "Madrid", pop: 47_000_000, continent: "Europe" }
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
# GET — list all (public)
|
|
480
|
+
pais_api.get('/countries', auth: false) do
|
|
481
|
+
{ total: COUNTRIES.size, countries: COUNTRIES }
|
|
283
482
|
end
|
|
284
483
|
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
]
|
|
291
|
-
{ users: users, total: users.length }
|
|
484
|
+
# GET — single country (:id cast to Integer automatically)
|
|
485
|
+
pais_api.get('/countries/:id', auth: false) do |params|
|
|
486
|
+
c = COUNTRIES.find { |x| x[:id] == params[:id] }
|
|
487
|
+
status 404 and next { error: "Not found" } unless c
|
|
488
|
+
c
|
|
292
489
|
end
|
|
293
490
|
|
|
294
|
-
#
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
next { error: "User not found" }
|
|
299
|
-
end
|
|
300
|
-
{ id: params[:id], name: "User_#{params[:id]}" }
|
|
491
|
+
# GET — search by name
|
|
492
|
+
pais_api.get('/countries/search', auth: false, requires: [:name]) do |params|
|
|
493
|
+
results = COUNTRIES.select { |c| c[:name].downcase.include?(params[:name].downcase) }
|
|
494
|
+
{ total: results.size, countries: results }
|
|
301
495
|
end
|
|
302
496
|
|
|
303
|
-
#
|
|
304
|
-
|
|
497
|
+
# POST — create (protected)
|
|
498
|
+
pais_api.post('/countries', auth: true, requires: [:name, :capital, :pop]) do |params|
|
|
499
|
+
c = { id: COUNTRIES.size + 1, name: params[:name],
|
|
500
|
+
capital: params[:capital], pop: params[:pop],
|
|
501
|
+
continent: params[:continent] || "Unknown" }
|
|
502
|
+
COUNTRIES << c
|
|
305
503
|
status 201
|
|
306
|
-
{ message: "
|
|
504
|
+
{ message: "Country created", country: c }
|
|
307
505
|
end
|
|
308
506
|
|
|
309
|
-
#
|
|
310
|
-
|
|
311
|
-
|
|
507
|
+
# PUT — full replace (protected)
|
|
508
|
+
pais_api.put('/countries/:id', auth: true, requires: [:name, :capital]) do |params|
|
|
509
|
+
c = COUNTRIES.find { |x| x[:id] == params[:id] }
|
|
510
|
+
status 404 and next { error: "Not found" } unless c
|
|
511
|
+
c[:name] = params[:name]; c[:capital] = params[:capital]
|
|
512
|
+
c[:pop] = params[:pop] if params[:pop]
|
|
513
|
+
{ message: "Updated", country: c }
|
|
312
514
|
end
|
|
313
515
|
|
|
314
|
-
#
|
|
315
|
-
|
|
316
|
-
{
|
|
516
|
+
# PATCH — partial update (protected)
|
|
517
|
+
pais_api.patch('/countries/:id', auth: true) do |params|
|
|
518
|
+
c = COUNTRIES.find { |x| x[:id] == params[:id] }
|
|
519
|
+
status 404 and next { error: "Not found" } unless c
|
|
520
|
+
[:name, :capital, :pop, :continent].each { |k| c[k] = params[k] if params[k] }
|
|
521
|
+
{ message: "Patched", country: c }
|
|
317
522
|
end
|
|
318
523
|
|
|
319
|
-
|
|
524
|
+
# DELETE (protected)
|
|
525
|
+
pais_api.delete('/countries/:id', auth: true) do |params|
|
|
526
|
+
c = COUNTRIES.find { |x| x[:id] == params[:id] }
|
|
527
|
+
status 404 and next { error: "Not found" } unless c
|
|
528
|
+
COUNTRIES.delete(c)
|
|
529
|
+
{ message: "Deleted", id: params[:id] }
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# POST — upload flag (multipart, protected)
|
|
533
|
+
pais_api.post('/countries/:id/flag', auth: true) do |params|
|
|
534
|
+
file = params[:_files]&.dig(:flag)
|
|
535
|
+
halt 400, { error: 'Field "flag" required' }.to_json unless file
|
|
536
|
+
halt 400, { error: "Format not allowed: #{file.extension}" }.to_json \
|
|
537
|
+
unless %w[.jpg .jpeg .png .svg .webp].include?(file.extension)
|
|
538
|
+
|
|
539
|
+
file.save_to("/uploads/flags/#{params[:id]}#{file.extension}")
|
|
540
|
+
status 201
|
|
541
|
+
{ message: "Flag uploaded", file: file.to_h }
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# GET — download flag (public, binary response)
|
|
545
|
+
pais_api.get('/countries/:id/flag', auth: false) do |params|
|
|
546
|
+
f = Dir.glob("/uploads/flags/#{params[:id]}.*").first
|
|
547
|
+
halt 404, { error: "Flag not found" }.to_json unless f
|
|
548
|
+
|
|
549
|
+
content_type 'application/octet-stream'
|
|
550
|
+
response.headers['Content-Disposition'] = "attachment; filename=\"#{File.basename(f)}\""
|
|
551
|
+
File.binread(f)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
pais_api.run!
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
```bash
|
|
558
|
+
curl http://localhost:4567/geo/v1/countries
|
|
559
|
+
curl http://localhost:4567/geo/v1/countries/1
|
|
560
|
+
curl -X POST http://localhost:4567/geo/v1/countries \
|
|
561
|
+
-H "Authorization: Bearer geo_secret_2024" \
|
|
562
|
+
-H "Content-Type: application/json" \
|
|
563
|
+
-d '{"name":"Brazil","capital":"Brasilia","pop":215000000}'
|
|
564
|
+
curl -X PATCH http://localhost:4567/geo/v1/countries/1 \
|
|
565
|
+
-H "Authorization: Bearer geo_secret_2024" \
|
|
566
|
+
-H "Content-Type: application/json" \
|
|
567
|
+
-d '{"pop":130000000}'
|
|
568
|
+
curl -X POST http://localhost:4567/geo/v1/countries/1/flag \
|
|
569
|
+
-H "Authorization: Bearer geo_secret_2024" \
|
|
570
|
+
-F "flag=@mexico_flag.png"
|
|
571
|
+
curl -X DELETE http://localhost:4567/geo/v1/countries/3 \
|
|
572
|
+
-H "Authorization: Bearer geo_secret_2024"
|
|
320
573
|
```
|
|
321
574
|
|
|
322
|
-
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## High Traffic & Concurrency
|
|
578
|
+
|
|
579
|
+
GR API Manager is built on **Puma** (the standard production Ruby server) and handles concurrent traffic out of the box. Here's how each layer works and what you can tune.
|
|
580
|
+
|
|
581
|
+
### How it handles concurrency
|
|
582
|
+
|
|
583
|
+
Puma uses a **multi-worker + multi-thread** model:
|
|
584
|
+
- **Workers** = OS processes, each with its own memory. More workers = more CPU cores used.
|
|
585
|
+
- **Threads** = lightweight concurrent handlers inside each worker. Threads share memory.
|
|
323
586
|
|
|
324
|
-
```env
|
|
325
|
-
# .env
|
|
326
|
-
PORT=4567
|
|
327
|
-
API_TOKEN=secret123
|
|
328
587
|
```
|
|
588
|
+
Request → Worker 1 → Thread A → route handler
|
|
589
|
+
→ Thread B → route handler
|
|
590
|
+
→ ...
|
|
591
|
+
→ Worker 2 → Thread A → route handler
|
|
592
|
+
→ ...
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
A server with `workers: 4, threads: '2:8'` can handle up to **32 simultaneous requests** before queuing.
|
|
596
|
+
|
|
597
|
+
### Configuring workers and threads
|
|
598
|
+
|
|
599
|
+
Pass them directly to `run!`:
|
|
329
600
|
|
|
330
601
|
```ruby
|
|
331
|
-
|
|
602
|
+
api.run!(
|
|
603
|
+
workers: 4, # Number of Puma worker processes
|
|
604
|
+
threads: '2:8' # min_threads:max_threads per worker
|
|
605
|
+
)
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Or via environment variables (recommended for production):
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
WEB_CONCURRENCY=4 ruby app.rb
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**Practical starting points:**
|
|
615
|
+
|
|
616
|
+
| Scenario | Workers | Threads |
|
|
617
|
+
|---|---|---|
|
|
618
|
+
| Development / local | 1 | `1:4` |
|
|
619
|
+
| Small server (1–2 CPU cores) | 2 | `2:8` |
|
|
620
|
+
| Medium server (4 CPU cores) | 4 | `2:8` |
|
|
621
|
+
| High-traffic (8+ CPU cores) | 8 | `4:16` |
|
|
622
|
+
| I/O-heavy (DB, external APIs) | 2 | `8:32` |
|
|
623
|
+
|
|
624
|
+
### Built-in rate limiting
|
|
625
|
+
|
|
626
|
+
Protect your server from flooding and abusive clients with the built-in sliding-window rate limiter:
|
|
627
|
+
|
|
628
|
+
```ruby
|
|
629
|
+
api = GRApiManager::Server.new(
|
|
630
|
+
bearer_token: "secret",
|
|
631
|
+
rate_limit: 100, # max 100 requests per IP
|
|
632
|
+
rate_limit_window: 60 # per 60-second window
|
|
633
|
+
)
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
When a client exceeds the limit, they receive `429 Too Many Requests`:
|
|
637
|
+
|
|
638
|
+
```json
|
|
639
|
+
{ "error": "Too many requests", "retry_after_seconds": 60 }
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Response headers are automatically added to every request:
|
|
643
|
+
|
|
644
|
+
```
|
|
645
|
+
X-RateLimit-Limit: 100
|
|
646
|
+
X-RateLimit-Remaining: 47
|
|
647
|
+
Retry-After: 60 (only on 429 responses)
|
|
648
|
+
X-RateLimit-Reset: 1721620800
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
The limiter is **thread-safe** (uses a `Mutex`) and runs a background cleanup thread automatically to prevent memory growth from tracked IPs.
|
|
652
|
+
|
|
653
|
+
**Common rate limit configs:**
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
# Public API — moderate protection
|
|
657
|
+
GRApiManager::Server.new(rate_limit: 200, rate_limit_window: 60)
|
|
658
|
+
|
|
659
|
+
# Strict — auth endpoints, login, password reset
|
|
660
|
+
GRApiManager::Server.new(rate_limit: 10, rate_limit_window: 60)
|
|
661
|
+
|
|
662
|
+
# Generous — internal service behind a trusted proxy
|
|
663
|
+
GRApiManager::Server.new(rate_limit: 2000, rate_limit_window: 60)
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### Production architecture for truly massive traffic
|
|
667
|
+
|
|
668
|
+
For thousands of concurrent users, add layers in front of the framework:
|
|
669
|
+
|
|
670
|
+
```
|
|
671
|
+
Internet
|
|
672
|
+
│
|
|
673
|
+
▼
|
|
674
|
+
[Nginx] ← terminates SSL, serves static files, load balances
|
|
675
|
+
│
|
|
676
|
+
├──▶ GR API Manager (worker 1, port 4567)
|
|
677
|
+
├──▶ GR API Manager (worker 2, port 4568)
|
|
678
|
+
└──▶ GR API Manager (worker 3, port 4569)
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
**Minimal Nginx config for load balancing:**
|
|
682
|
+
|
|
683
|
+
```nginx
|
|
684
|
+
upstream gr_api {
|
|
685
|
+
server 127.0.0.1:4567;
|
|
686
|
+
server 127.0.0.1:4568;
|
|
687
|
+
server 127.0.0.1:4569;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
server {
|
|
691
|
+
listen 80;
|
|
692
|
+
server_name api.yourdomain.com;
|
|
693
|
+
|
|
694
|
+
location / {
|
|
695
|
+
proxy_pass http://gr_api;
|
|
696
|
+
proxy_set_header Host $host;
|
|
697
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
698
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
### What the banner shows
|
|
704
|
+
|
|
705
|
+
```text
|
|
706
|
+
=============================================
|
|
707
|
+
GR API MANAGER STARTED
|
|
708
|
+
Port : 4567
|
|
709
|
+
Auth : Enabled
|
|
710
|
+
Prefix : /api/v1
|
|
711
|
+
Max Body : 50.0 MB
|
|
712
|
+
Workers : 4 | Threads: 2:8
|
|
713
|
+
Rate Limit: 100 req / 60s per IP
|
|
714
|
+
Dev Mode : Off
|
|
715
|
+
=============================================
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
---
|
|
719
|
+
|
|
720
|
+
## Multiple Route Groups on One Server
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
You don't need to spin up separate servers for different API sections. Register all your route groups on **a single server instance** — one process, one port, zero extra resource cost.
|
|
724
|
+
|
|
725
|
+
The cleanest pattern is to extract each group into its own file and pass the server object as an argument:
|
|
726
|
+
|
|
727
|
+
```
|
|
728
|
+
project/
|
|
729
|
+
├── main.rb
|
|
730
|
+
└── routes/
|
|
731
|
+
├── users.rb
|
|
732
|
+
├── countries.rb
|
|
733
|
+
└── products.rb
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
```ruby
|
|
737
|
+
# routes/users.rb
|
|
738
|
+
def register_users(api)
|
|
739
|
+
api.get('/users', auth: false) do
|
|
740
|
+
{ users: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] }
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
api.post('/users', auth: true, requires: [:name, :email]) do |params|
|
|
744
|
+
status 201
|
|
745
|
+
{ message: "User created", user: { name: params[:name], email: params[:email] } }
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
api.delete('/users/:id', auth: true) do |params|
|
|
749
|
+
{ message: "User #{params[:id]} deleted" }
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
```ruby
|
|
755
|
+
# routes/countries.rb
|
|
756
|
+
def register_countries(api)
|
|
757
|
+
COUNTRIES = [
|
|
758
|
+
{ id: 1, name: "Mexico", capital: "Mexico City" },
|
|
759
|
+
{ id: 2, name: "Spain", capital: "Madrid" }
|
|
760
|
+
]
|
|
761
|
+
|
|
762
|
+
api.get('/countries', auth: false) do
|
|
763
|
+
{ total: COUNTRIES.size, countries: COUNTRIES }
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
api.get('/countries/:id', auth: false) do |params|
|
|
767
|
+
c = COUNTRIES.find { |x| x[:id] == params[:id] }
|
|
768
|
+
status 404 and next { error: "Not found" } unless c
|
|
769
|
+
c
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
```ruby
|
|
775
|
+
# routes/products.rb
|
|
776
|
+
def register_products(api)
|
|
777
|
+
api.get('/products', auth: false) do
|
|
778
|
+
{ products: [{ id: 1, name: "Laptop", price: 999.99 }] }
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
api.post('/products', auth: true, requires: [:name, :price]) do |params|
|
|
782
|
+
status 201
|
|
783
|
+
{ message: "Product created", product: { name: params[:name], price: params[:price] } }
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
```ruby
|
|
789
|
+
# main.rb — one server, three route groups, one process
|
|
332
790
|
require 'gr_api_manager'
|
|
791
|
+
require_relative 'routes/users'
|
|
792
|
+
require_relative 'routes/countries'
|
|
793
|
+
require_relative 'routes/products'
|
|
333
794
|
|
|
334
|
-
api = GRApiManager::Server.new
|
|
795
|
+
api = GRApiManager::Server.new(
|
|
796
|
+
port: 4567,
|
|
797
|
+
bearer_token: "secret",
|
|
798
|
+
prefix: "/api/v1"
|
|
799
|
+
)
|
|
335
800
|
|
|
336
|
-
api
|
|
801
|
+
register_users(api) # mounts: GET/POST /api/v1/users, DELETE /api/v1/users/:id
|
|
802
|
+
register_countries(api) # mounts: GET /api/v1/countries, GET /api/v1/countries/:id
|
|
803
|
+
register_products(api) # mounts: GET/POST /api/v1/products
|
|
337
804
|
|
|
338
805
|
api.run!
|
|
806
|
+
# All routes available on a single process at localhost:4567
|
|
339
807
|
```
|
|
340
808
|
|
|
809
|
+
```bash
|
|
810
|
+
# All three groups work on the same server
|
|
811
|
+
curl http://localhost:4567/api/v1/users
|
|
812
|
+
curl http://localhost:4567/api/v1/countries
|
|
813
|
+
curl http://localhost:4567/api/v1/products
|
|
814
|
+
|
|
815
|
+
curl -X POST http://localhost:4567/api/v1/users \
|
|
816
|
+
-H "Authorization: Bearer secret" \
|
|
817
|
+
-H "Content-Type: application/json" \
|
|
818
|
+
-d '{"name":"Carlos","email":"carlos@example.com"}'
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
This approach scales cleanly — add new route groups without touching `main.rb` logic, and everything shares the same auth token, prefix, and body size limit.
|
|
822
|
+
|
|
341
823
|
---
|
|
342
824
|
|
|
343
825
|
## License
|
|
344
826
|
|
|
345
|
-
|
|
827
|
+
Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|