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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +645 -163
  3. data/lib/gr_api_manager.rb +448 -52
  4. metadata +21 -4
data/README.md CHANGED
@@ -1,164 +1,185 @@
1
1
  # GR API Manager
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/gr-api-manager.svg)](https://rubygems.org/gems/gr-api-manager)
4
+ [![README en Español](https://img.shields.io/badge/README-Español-blue)](README_ES.md)
4
5
 
5
- A minimal, opinionated Ruby wrapper around Sinatra that eliminates boilerplate from REST API development. Authentication, parameter validation, type casting, CORS, and error handling are handled at the framework level you write only the business logic.
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
- - **Available on RubyGems!** Install globally and use it in any project.
12
- - Route-level Bearer Token authentication (opt-in/opt-out per endpoint).
13
- - Declarative required parameter validation with automatic `400 Bad Request` responses.
14
- - Smart type casting for query string values (`"10"` -> `Integer`, `"true"` -> `TrueClass`, `"9.5"` -> `Float`).
15
- - Global JSON error responses for `404` and `500`.
16
- - Broad CORS and `OPTIONS` preflight handling out of the box (Frontend ready).
17
- - API versioning via configurable route prefix (e.g. `/api/v1`).
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
- api = GRApiManager::Server.new(
47
- port: 4567,
48
- bearer_token: "your_secret_token",
49
- prefix: "/api/v1"
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: 4567, # Default: ENV['PORT'] || 4000
74
- bearer_token: "secret", # Default: ENV['API_TOKEN']
75
- permitted_hosts: ["example.com"], # Host allowlist — empty means allow all
76
- prefix: "/api/v1" # Route prefix applied to all endpoints
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
- ### Using a .env file (Recommended)
69
+ ### Size helpers
81
70
 
82
- Create a `.env` file at the root of your project:
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
- require 'gr_api_manager'
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
- This is the recommended approach for production — keep secrets out of source code and out of version control. Add `.env` to your `.gitignore`.
95
+ Explicit arguments always override `.env`. Add `.env` to `.gitignore`.
99
96
 
100
- ```text
101
- # .gitignore
102
- .env
103
- ```
97
+ ---
104
98
 
105
- ### Priority order
99
+ ## HTTP Verbs — what each one does
106
100
 
107
- When a value is provided both in code and in `.env`, the explicit argument always wins:
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
- ```text
110
- new(bearer_token: "hardcoded") > ENV['API_TOKEN'] > nil (auth disabled)
111
- new(port: 4567) > ENV['PORT'] > 4000
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.get('/users', auth: true, requires: [:role, :age]) do |params|
122
- # params is a merged, type-cast hash of all inputs
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
- ### Options
131
-
132
- | Option | Type | Default | Description |
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
- ## Authentication
136
+ ## Success Responses
140
137
 
141
- All routes require a valid Bearer Token by default. Pass the token in the `Authorization` header:
138
+ Return any Hash or Array the framework serializes to JSON automatically.
142
139
 
143
- ```bash
144
- curl -H "Authorization: Bearer your_secret_token" http://localhost:4567/api/v1/users
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
- To make a route public, set `auth: false`:
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) do
151
- { status: 'online' }
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
- **Automatic Error Responses:**
174
+ ```bash
175
+ curl -H "Authorization: Bearer your_token" http://localhost:4567/api/v1/data
176
+ ```
156
177
 
157
178
  ```json
158
- // Missing header (401)
179
+ // 401 — missing header
159
180
  { "error": "Token required. Format: 'Bearer <token>'" }
160
181
 
161
- // Wrong token (403)
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": "Gabo"}'
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 parameters are automatically cast to native Ruby types before reaching your handler.
210
+ Query string values are cast to native Ruby types before reaching your block:
192
211
 
193
- | Input string | Ruby type | Value |
194
- |--------------|-----------|----------|
195
- | `"42"` | Integer | `42` |
196
- | `"3.14"` | Float | `3.14` |
197
- | `"true"` | TrueClass | `true` |
198
- | `"false"` | FalseClass| `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/test/types?age=18&active=true&score=9.5" \
203
- -H "Authorization: Bearer secret"
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
- # => { "age": 18, "active": true, "score": 9.5 }
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
- ## Error Handling
385
+ ## Dev Mode
386
+
387
+ Enable during development for full stack traces on `500` errors:
211
388
 
212
- Global handlers return consistent JSON for unmatched routes and unhandled exceptions.
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
- ```ruby
225
- api.get('/users/:id') do |params|
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 /api/v1/health - 200
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 : 4567
260
- Auth : Enabled
261
- Prefix : /api/v1
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
- ## Full Usage Example
455
+ ## Complete Example — Country API
268
456
 
269
- Below is a complete working API covering the most common patterns.
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
- api = GRApiManager::Server.new(
275
- port: 4567,
276
- bearer_token: "secret123",
277
- prefix: "/api/v1"
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
- # 1. Public health check — no token required
281
- api.get('/health', auth: false) do
282
- { status: 'online', time: Time.now.strftime("%Y-%m-%d %H:%M:%S") }
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
- # 2. List users token required (default)
286
- api.get('/users') do |params|
287
- users = [
288
- { id: 1, name: "Alice", role: "admin" },
289
- { id: 2, name: "Bob", role: "viewer" }
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
- # 3. Get single user by ID — :id is cast to Integer automatically
295
- api.get('/users/:id') do |params|
296
- if params[:id] == 0
297
- status 404
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
- # 4. Create user validates required fields before reaching the block
304
- api.post('/users', requires: [:name, :role]) do |params|
497
+ # POSTcreate (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: "User created", user: { name: params[:name], role: params[:role] } }
504
+ { message: "Country created", country: c }
307
505
  end
308
506
 
309
- # 5. Update user
310
- api.put('/users/:id', requires: [:name]) do |params|
311
- { message: "User #{params[:id]} updated", name: params[:name] }
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
- # 6. Delete user
315
- api.delete('/users/:id') do |params|
316
- { message: "User #{params[:id]} deleted" }
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
- api.run!
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
- ### Minimal setup using only .env
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
- # api_server.rb
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 # reads everything from .env
795
+ api = GRApiManager::Server.new(
796
+ port: 4567,
797
+ bearer_token: "secret",
798
+ prefix: "/api/v1"
799
+ )
335
800
 
336
- api.get('/ping', auth: false) { { pong: true } }
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
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
827
+ Available as open source under the [MIT License](https://opensource.org/licenses/MIT).