gr_api_manager 0.3.0 → 0.4.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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +424 -637
  3. data/README_ES.md +615 -0
  4. data/lib/gr_api_manager.rb +449 -138
  5. metadata +60 -13
data/README.md CHANGED
@@ -1,827 +1,614 @@
1
1
  # GR API Manager
2
2
 
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)
3
+ [![Gem Version](https://img.shields.io/gem/v/gr_api_manager.svg?logo=rubygems&logoColor=white&color=e9573f)](https://rubygems.org/gems/gr_api_manager)
4
+ [![Gem Total Downloads](https://img.shields.io/gem/dt/gr_api_manager.svg?logo=rubygems&logoColor=white&color=00bfa5)](https://rubygems.org/gems/gr_api_manager)
5
+ [![Ruby Version](https://img.shields.io/badge/Ruby-%3E%3D%203.0-cc342d.svg?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
6
+ [![Sinatra Version](https://img.shields.io/badge/Sinatra-%3E%3D%203.0-008080.svg?logo=sinatra&logoColor=white)](https://sinatrarb.com/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-007ec6.svg?logo=open-source-initiative&logoColor=white)](LICENSE)
8
+ [![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-555555.svg?logo=linux&logoColor=white)](https://rubygems.org/gems/gr_api_manager)
9
+ [![Tests](https://img.shields.io/badge/Tests-59%2F59%20Passing-4c1.svg?logo=checkmarx&logoColor=white)](spec/)
10
+ [![README en Español](https://img.shields.io/badge/README-Espa%C3%B1ol-red.svg?logo=google-translate&logoColor=white)](README_ES.md)
5
11
 
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.
12
+ **GR API Manager** is a minimalist, high-performance Ruby wrapper on top of Sinatra and Puma, designed to build production-grade REST APIs with zero boilerplate.
7
13
 
8
14
  ---
9
15
 
10
- ## Features
16
+ ## System Requirements
11
17
 
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
+ | Dependency / Environment | Required / Supported Version |
19
+ |---|---|
20
+ | **Ruby** | `>= 3.0` (Tested on 3.0, 3.1, 3.2, 3.3, and 3.4) |
21
+ | **Sinatra** | `>= 3.0, < 5.0` |
22
+ | **Puma** | `>= 5.0, < 9.0` |
23
+ | **Dotenv** | `>= 2.8, < 4.0` |
24
+
25
+ ---
26
+
27
+ ## Key Features
28
+
29
+ - **Available on RubyGems (`0.4.0`)** - global installation or via Bundler.
30
+ - **Modular Route Groups (`api.group`)** - organize large projects into clean, multi-file sub-routers with prefix & option inheritance.
31
+ - **Dual Authentication (Static Bearer Token & Native JWT)** - support for pre-shared static tokens and zero-dependency built-in HMAC-SHA256 JWT engine.
32
+ - **Declarative Schema & Type Validation** - validate complex request contracts with `:email`, `:url`, `:boolean`, `:file`, classes (`Integer`, `Float`, `String`, `Array`, `Hash`), enum lists, regular expressions, or custom procs.
33
+ - **Sliding-Window Rate Limiting (429)** - sliding-window rate limiter with automatic client IP detection behind Cloudflare (`CF-Connecting-IP`), Nginx (`X-Real-IP`), or Reverse Proxies (`X-Forwarded-For`), supporting in-memory or pluggable stores (Redis).
34
+ - **Smart Parameter Casting** - automatic type casting for query and URL parameters (`"100"` -> `100`, `"-42"` -> `-42`, `"true"` -> `true`, `"19.99"` -> `19.99`).
35
+ - **Unified File & Binary Handling (`FilePayload`)** - transparent handling for multipart uploads, raw binary streams, Base64/Hex encoding, file serving/downloads, and disk storage with directory auto-creation.
36
+ - **Puma Concurrency** - configurable multi-process workers and thread pools.
37
+ - **Developer Mode (`dev_mode`)** - structured JSON error responses with full stack traces for painless debugging.
22
38
 
23
39
  ---
24
40
 
25
41
  ## Installation
26
42
 
43
+ Add the gem to your `Gemfile`:
27
44
  ```ruby
28
- gem 'gr-api-manager' # Gemfile
45
+ gem 'gr_api_manager', '~> 0.4.0'
29
46
  ```
30
47
 
48
+ And run:
31
49
  ```bash
32
50
  bundle install
33
- # or: gem install gr-api-manager
34
51
  ```
35
52
 
36
- ---
37
-
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.
41
-
42
- ```ruby
43
- require 'gr_api_manager'
44
-
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))
49
-
50
- api.get('/health', auth: false) { { status: 'ok' } }
51
- api.run!
53
+ Or install directly:
54
+ ```bash
55
+ gem install gr_api_manager
52
56
  ```
53
57
 
54
58
  ---
55
59
 
56
- ## Configuration
60
+ ## Table of Contents
57
61
 
58
- ```ruby
59
- GRApiManager::Server.new(
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
66
- )
67
- ```
68
-
69
- ### Size helpers
70
-
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
62
+ 1. [Quickstart](#quickstart)
63
+ 2. [Authentication: Static Bearer Token & Native JWT](#authentication-static-bearer-token--native-jwt)
64
+ 3. [Multi-File Modular Architecture (Importing & Grouping)](#multi-file-modular-architecture)
65
+ 4. [Full REST CRUD with HTTP Verbs](#full-rest-crud-with-http-verbs)
66
+ 5. [Declarative Schema & Type Validation](#declarative-schema--type-validation)
67
+ 6. [Comprehensive File, Binary & Image Handling](#comprehensive-file-binary--image-handling)
68
+ 7. [Serving & Downloading Files to Clients](#serving--downloading-files-to-clients)
69
+ 8. [Rate Limiting & Real IP Detection (Cloudflare/Nginx)](#rate-limiting--real-ip-detection)
70
+ 9. [Smart Parameter Casting](#smart-parameter-casting)
71
+ 10. [Responses, HTTP Status Codes & Dev Mode](#responses-http-status-codes--dev-mode)
72
+ 11. [Concurrency & Production Deployment (Puma & Docker)](#concurrency--production-deployment)
77
73
 
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
- ```
74
+ ---
83
75
 
84
- ### `.env` file (recommended for production)
76
+ ## Quickstart
85
77
 
86
- ```env
87
- PORT=4567
88
- API_TOKEN=your_secret_token
89
- ```
78
+ Create an `app.rb` file:
90
79
 
91
80
  ```ruby
92
- api = GRApiManager::Server.new # picks up PORT and API_TOKEN automatically
93
- ```
81
+ require 'gr_api_manager'
94
82
 
95
- Explicit arguments always override `.env`. Add `.env` to `.gitignore`.
83
+ # Initialize the server with static token and JWT key
84
+ api = GRApiManager::Server.new(
85
+ port: 4000,
86
+ bearer_token: "my_static_secret_token",
87
+ jwt_secret: "my_jwt_signing_secret"
88
+ )
96
89
 
97
- ---
90
+ # Public endpoint (no auth required)
91
+ api.get('/health', auth: false) do
92
+ { status: 'online', timestamp: Time.now.to_i }
93
+ end
98
94
 
99
- ## HTTP Verbs what each one does
95
+ # Protected endpoint with static token (default: auth = true)
96
+ api.get('/protected-data') do
97
+ { message: "Access granted with Bearer Token", data: [10, 20, 30] }
98
+ end
100
99
 
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 |
100
+ # Start the server
101
+ api.run!
102
+ ```
108
103
 
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
104
+ Run your API:
105
+ ```bash
106
+ ruby app.rb
116
107
  ```
117
108
 
118
109
  ---
119
110
 
120
- ## Defining Routes
121
-
122
- ```ruby
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" }
126
- end
127
- ```
128
-
129
- | Option | Type | Default | Description |
130
- |---|---|---|---|
131
- | `auth` | Boolean | `true` | Require Bearer Token |
132
- | `requires` | Array | `[]` | Required parameter keys |
111
+ ## Authentication: Static Bearer Token & Native JWT
133
112
 
134
- ---
113
+ `gr_api_manager` supports two complementary authentication schemes:
135
114
 
136
- ## Success Responses
115
+ ### 1. Static Bearer Token
137
116
 
138
- Return any Hash or Array the framework serializes to JSON automatically.
117
+ Ideal for internal services, webhooks, microservices, or backend automation using a pre-shared static key.
139
118
 
119
+ #### Configuration in `app.rb`:
140
120
  ```ruby
141
- # Simple hash
142
- api.get('/ping', auth: false) { { pong: true } }
121
+ api = GRApiManager::Server.new(
122
+ bearer_token: "my_secret_company_token_2026"
123
+ )
143
124
 
144
- # Set a status code
145
- api.post('/users', requires: [:name]) do |params|
146
- status 201
147
- { message: "Created", user: { name: params[:name] } }
125
+ # Public route
126
+ api.get('/public', auth: false) do
127
+ { status: "open access" }
148
128
  end
149
129
 
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" }
130
+ # Protected routes (default auth: true)
131
+ api.get('/admin/config') do
132
+ { database: "connected", environment: "production" }
157
133
  end
158
- ```
159
134
 
160
- > Returning a `String` sends it **as-is** (no JSON wrapping) useful for binary file responses.
161
-
162
- ---
163
-
164
- ## Authentication
165
-
166
- ```ruby
167
- api.get('/health', auth: false) { { status: 'online' } } # public
168
-
169
- api.get('/data') do |params| # protected (default)
170
- { secret: "data" }
135
+ api.post('/admin/restart', requires: [:reason]) do |params|
136
+ { action: "restarting", reason: params[:reason] }
171
137
  end
172
138
  ```
173
139
 
174
- ```bash
175
- curl -H "Authorization: Bearer your_token" http://localhost:4567/api/v1/data
176
- ```
177
-
178
- ```json
179
- // 401 — missing header
180
- { "error": "Token required. Format: 'Bearer <token>'" }
181
-
182
- // 403 — wrong token
183
- { "error": "Invalid token" }
184
- ```
185
-
186
- ---
187
-
188
- ## Parameter Validation
140
+ #### Consuming via cURL:
189
141
 
190
- ```ruby
191
- api.post('/users', requires: [:name, :email, :role]) do |params|
192
- status 201
193
- { message: "User created", user: params }
194
- end
142
+ **Valid request (200 OK):**
143
+ ```bash
144
+ curl -H "Authorization: Bearer my_secret_company_token_2026" http://localhost:4000/admin/config
145
+ # => {"database":"connected","environment":"production"}
195
146
  ```
196
147
 
148
+ **Missing Authorization header (401 Unauthorized):**
197
149
  ```bash
198
- curl -X POST http://localhost:4567/api/v1/users \
199
- -H "Authorization: Bearer secret" \
200
- -H "Content-Type: application/json" \
201
- -d '{"name":"Gabo"}'
202
-
203
- # => 400 { "error": "Missing required parameters", "required": ["email", "role"] }
150
+ curl http://localhost:4000/admin/config
151
+ # => 401 {"error":"Token required. Format: 'Bearer <token>'"}
204
152
  ```
205
153
 
206
- ---
207
-
208
- ## Smart Type Casting
209
-
210
- Query string values are cast to native Ruby types before reaching your block:
211
-
212
- | String | Ruby type | Value |
213
- |---|---|---|
214
- | `"42"` | Integer | `42` |
215
- | `"3.14"` | Float | `3.14` |
216
- | `"true"` | TrueClass | `true` |
217
- | `"false"` | FalseClass | `false` |
218
-
154
+ **Invalid token (403 Forbidden):**
219
155
  ```bash
220
- curl "http://localhost:4567/api/v1/products?page=2&active=true&price=9.99"
221
- # params => { page: 2, active: true, price: 9.99 }
156
+ curl -H "Authorization: Bearer wrong_token" http://localhost:4000/admin/config
157
+ # => 403 {"error":"Invalid token"}
222
158
  ```
223
159
 
224
160
  ---
225
161
 
226
- ## File & Binary Body Handling
227
-
228
- The framework auto-detects `Content-Type` and parses accordingly. **No extra setup needed.**
229
-
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
162
+ ### 2. Native JWT Authentication (HS256)
242
163
 
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
164
+ Ideal for end-user facing APIs issuing dynamic tokens with expiration and claims.
256
165
 
166
+ #### Complete Workflow:
257
167
  ```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)
168
+ api = GRApiManager::Server.new(
169
+ jwt_secret: "my_super_secret_jwt_key"
170
+ )
262
171
 
263
- file.save_to("/uploads/#{file.filename}")
264
- status 201
265
- { message: "Uploaded", file: file.to_h }
172
+ # 1. Login: issue and return token
173
+ api.post('/auth/login', auth: false, requires: { email: :email, password: String }) do |params|
174
+ if params[:email] == "admin@company.com" && params[:password] == "pass123"
175
+ token = api.jwt_encode(
176
+ { user_id: 42, email: params[:email], role: "admin" },
177
+ exp: Time.now.to_i + 3600 # 1-hour expiration
178
+ )
179
+ { token: token, token_type: "Bearer", expires_in: 3600 }
180
+ else
181
+ status 401
182
+ { error: "Invalid credentials" }
183
+ end
266
184
  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
185
 
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 }
186
+ # 2. JWT-protected route: automatically injects params[:current_user]
187
+ api.get('/profile', auth: :jwt) do |params|
188
+ user = params[:current_user]
189
+ {
190
+ message: "Valid JWT token",
191
+ user_id: user[:user_id],
192
+ email: user[:email],
193
+ role: user[:role]
194
+ }
285
195
  end
286
196
  ```
287
197
 
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
198
+ ---
317
199
 
318
- ```ruby
319
- require 'base64'
200
+ ## Multi-File Modular Architecture
320
201
 
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
- ```
202
+ Divide your API into dedicated, clean route modules within a `routes/` directory:
327
203
 
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\"}"
204
+ ```text
205
+ my_api_project/
206
+ ├── app.rb # Main entrypoint and configuration
207
+ ├── .env # Environment variables
208
+ ├── Gemfile
209
+ └── routes/
210
+ ├── auth_routes.rb # Login & registration
211
+ ├── admin_routes.rb # Admin dashboard
212
+ ├── payments_routes.rb # Checkout & billing
213
+ └── files_routes.rb # File uploads & storage
334
214
  ```
335
215
 
336
- ### Hexadecimal
337
-
216
+ ### Module 1: `routes/auth_routes.rb`
338
217
  ```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 }
218
+ module AuthRoutes
219
+ def self.setup(router, api_server)
220
+ router.post('/login', auth: false, requires: { email: :email, password: String }) do |params|
221
+ if params[:email] == "admin@company.com" && params[:password] == "secret"
222
+ token = api_server.jwt_encode({ user_id: 1, email: params[:email], role: "admin" })
223
+ { token: token }
224
+ else
225
+ status 401
226
+ { error: "Invalid credentials" }
227
+ end
228
+ end
229
+ end
344
230
  end
345
231
  ```
346
232
 
347
- ### Plain text body
348
-
233
+ ### Module 2: `routes/admin_routes.rb`
349
234
  ```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 }
235
+ module AdminRoutes
236
+ def self.setup(router)
237
+ router.get('/metrics') do |params|
238
+ { cpu: "12%", memory: "380MB", user: params[:current_user][:email] }
239
+ end
240
+
241
+ router.delete('/users/:id') do |params|
242
+ { message: "User #{params[:id]} deleted" }
243
+ end
244
+ end
354
245
  end
355
246
  ```
356
247
 
357
- ### Serving a file as binary response
358
-
248
+ ### Module 3: `routes/files_routes.rb`
359
249
  ```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
250
+ module FilesRoutes
251
+ def self.setup(router)
252
+ router.post('/upload', requires: [:title]) do |params|
253
+ file = params[:_files][:document]
254
+ path = file.save_to("./storage/#{params[:title]}#{file.extension}")
255
+ { status: "saved", path: path, size: file.size }
256
+ end
257
+ end
367
258
  end
368
259
  ```
369
260
 
370
- ### Body size limit
371
-
372
- ```json
373
- // When exceeded → 413
374
- { "error": "Payload too large", "max_bytes": 52428800 }
375
- ```
376
-
261
+ ### Main Application File: `app.rb`
377
262
  ```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
381
- ```
382
-
383
- ---
384
-
385
- ## Dev Mode
263
+ require 'gr_api_manager'
386
264
 
387
- Enable during development for full stack traces on `500` errors:
265
+ # Require modular route files
266
+ require_relative 'routes/auth_routes'
267
+ require_relative 'routes/admin_routes'
268
+ require_relative 'routes/files_routes'
388
269
 
389
- ```ruby
390
270
  api = GRApiManager::Server.new(
391
- bearer_token: "secret",
392
- dev_mode: true # ⚠️ Disable in production
271
+ port: 4000,
272
+ jwt_secret: ENV['JWT_SECRET'] || "default_jwt_secret",
273
+ bearer_token: ENV['API_TOKEN'] || "global_static_token"
393
274
  )
394
- ```
395
275
 
396
- **Production (dev_mode: false):**
397
- ```json
398
- { "error": "Internal Server Error", "details": "undefined method 'foo' for nil" }
399
- ```
276
+ # Root route
277
+ api.get('/', auth: false) { { service: "Core API v1.0" } }
400
278
 
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
- ```
279
+ # Mount route groups
280
+ api.group('/auth') { |g| AuthRoutes.setup(g, api) }
281
+ api.group('/admin', auth: :jwt) { |g| AdminRoutes.setup(g) }
282
+ api.group('/files', auth: true) { |g| FilesRoutes.setup(g) }
410
283
 
411
- ---
412
-
413
- ## Error Handling
414
-
415
- ```json
416
- // 404
417
- { "error": "Endpoint not found", "path": "/api/v1/missing" }
418
-
419
- // 500
420
- { "error": "Internal Server Error", "details": "..." }
421
-
422
- // 413
423
- { "error": "Payload too large", "max_bytes": 52428800 }
284
+ api.run!(workers: 2, threads: '2:8')
424
285
  ```
425
286
 
426
287
  ---
427
288
 
428
- ## Request Logging
429
-
430
- ```text
431
- [14:32:01] GET /api/v1/health - 200
432
- [14:32:05] POST /api/v1/users - 400
433
- [14:32:10] POST /api/v1/upload/avatar - 201
434
- ```
289
+ ## Full REST CRUD with HTTP Verbs
435
290
 
436
- Green for 2xx, red for everything else.
437
-
438
- ---
439
-
440
- ## Running the Server
441
-
442
- ```text
443
- =============================================
444
- GR API MANAGER STARTED
445
- Port : 4567
446
- Auth : Enabled
447
- Prefix : /api/v1
448
- Max Body : 50.0 MB
449
- Dev Mode : Off
450
- =============================================
451
- ```
452
-
453
- ---
454
-
455
- ## Complete Example — Country API
456
-
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**.
291
+ Example managing a `/products` resource:
458
292
 
459
293
  ```ruby
460
- require 'gr_api_manager'
461
- require 'fileutils'
462
-
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
471
- )
472
-
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 }
294
+ api = GRApiManager::Server.new(prefix: '/api/v1')
295
+
296
+ # 1. LIST (GET) - with auto-cast query parameters
297
+ api.get('/products', auth: false) do |params|
298
+ page = params[:page] || 1 # Integer
299
+ limit = params[:limit] || 10 # Integer
300
+ active = params[:active] != false # Boolean
301
+
302
+ {
303
+ page: page,
304
+ limit: limit,
305
+ items: [
306
+ { id: 1, name: "Mechanical Keyboard", price: 89.99, active: true },
307
+ { id: 2, name: "4K Monitor", price: 299.99, active: true }
308
+ ]
309
+ }
482
310
  end
483
311
 
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
312
+ # 2. GET BY ID (GET)
313
+ api.get('/products/:id', auth: false) do |params|
314
+ id = params[:id] # Auto Integer
315
+ { id: id, name: "Product #{id}", price: 49.99 }
489
316
  end
490
317
 
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 }
495
- end
496
-
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
318
+ # 3. CREATE (POST) - with strong type validation
319
+ api.post('/products', requires: { name: String, price: Float, category: ['tech', 'office'] }) do |params|
503
320
  status 201
504
- { message: "Country created", country: c }
505
- end
506
-
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 }
321
+ {
322
+ message: "Product created",
323
+ product: { id: rand(100..999), name: params[:name], price: params[:price] }
324
+ }
514
325
  end
515
326
 
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 }
327
+ # 4. FULL UPDATE (PUT)
328
+ api.put('/products/:id', requires: { name: String, price: Float }) do |params|
329
+ {
330
+ message: "Product #{params[:id]} completely updated",
331
+ data: params
332
+ }
522
333
  end
523
334
 
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 }
335
+ # 5. PARTIAL UPDATE (PATCH)
336
+ api.patch('/products/:id') do |params|
337
+ {
338
+ message: "Updated fields for product #{params[:id]}",
339
+ changes: params.except(:id)
340
+ }
542
341
  end
543
342
 
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)
343
+ # 6. DELETE (DELETE)
344
+ api.delete('/products/:id') do |params|
345
+ { message: "Product #{params[:id]} deleted successfully" }
552
346
  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"
573
347
  ```
574
348
 
575
349
  ---
576
350
 
577
- ## High Traffic & Concurrency
351
+ ## Declarative Schema & Type Validation
578
352
 
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.
353
+ The `requires:` option validates data types, string formats, uploaded files, and custom logic:
580
354
 
581
- ### How it handles concurrency
355
+ ```ruby
356
+ api.post '/catalog', requires: {
357
+ sku: /^[A-Z]{3}-\d{4}$/, # Regexp: e.g. "PRO-1234"
358
+ title: String, # Non-empty string
359
+ price: Float, # Float number
360
+ stock: Integer, # Integer
361
+ active: :boolean, # true or false
362
+ category: ['electronics', 'home'], # Enum / List of allowed values
363
+ photo: :file, # Uploaded FilePayload
364
+ website: :url, # Valid URL (http/https)
365
+ contact: :email, # Valid email address
366
+ discount: ->(v) { v.to_f.between?(0, 100) } # Custom Lambda validation
367
+ } do |params|
368
+ status 201
369
+ { status: "ok", item: params[:title] }
370
+ end
371
+ ```
582
372
 
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.
373
+ ### Supported Validation Rules:
586
374
 
587
- ```
588
- Request → Worker 1 → Thread A → route handler
589
- Thread B → route handler
590
- ...
591
- Worker 2 Thread A → route handler
592
- ...
593
- ```
375
+ | Rule | Expected Format / Type | Valid Example |
376
+ |---|---|---|
377
+ | `:email` | Standard email format | `"contact@domain.com"` |
378
+ | `:url` | URL with `http://` or `https://` | `"https://api.domain.com"` |
379
+ | `:boolean` | Native boolean (`true` or `false`) | `true`, `false` |
380
+ | `:file` | Instance of `GRApiManager::FilePayload` | Multipart file upload |
381
+ | `Integer` | Integer number | `42`, `100`, `-10` |
382
+ | `Float` | Floating point number | `19.99`, `0.5`, `-3.14` |
383
+ | `Numeric` | Any numeric value (`Integer` or `Float`) | `10`, `3.14` |
384
+ | `String` | Non-empty string | `"Sample Text"` |
385
+ | `Array` | Array of elements | `[1, 2, 3]` |
386
+ | `Hash` | JSON Object or Hash | `{ key: "value" }` |
387
+ | `['a', 'b']` | Exact inclusion in list (Enum) | `'electronics'` |
388
+ | `/^regex$/` | Regular expression match | `"ABC-1234"` |
389
+ | `->(val) { ... }` | Custom Lambda/Proc (must return `true`) | `->(n) { n.to_i > 0 }` |
594
390
 
595
- A server with `workers: 4, threads: '2:8'` can handle up to **32 simultaneous requests** before queuing.
391
+ ---
596
392
 
597
- ### Configuring workers and threads
393
+ ## Comprehensive File, Binary & Image Handling
598
394
 
599
- Pass them directly to `run!`:
395
+ `gr_api_manager` detects incoming `Content-Type` headers and provides uniform file handling via `FilePayload`:
600
396
 
397
+ ### 1. Multipart Form Upload (`multipart/form-data`)
601
398
  ```ruby
602
- api.run!(
603
- workers: 4, # Number of Puma worker processes
604
- threads: '2:8' # min_threads:max_threads per worker
605
- )
399
+ api.post('/profile/avatar', requires: [:user_id]) do |params|
400
+ avatar = params[:_files][:avatar] # FilePayload instance
401
+
402
+ # Save to disk (creates directories automatically if missing)
403
+ saved_path = avatar.save_to("./storage/avatars/user_#{params[:user_id]}#{avatar.extension}")
404
+
405
+ {
406
+ message: "Avatar saved",
407
+ filename: avatar.filename,
408
+ size: avatar.size,
409
+ extension: avatar.extension,
410
+ saved_path: saved_path
411
+ }
412
+ end
606
413
  ```
607
414
 
608
- Or via environment variables (recommended for production):
609
-
415
+ #### cURL multipart:
610
416
  ```bash
611
- WEB_CONCURRENCY=4 ruby app.rb
417
+ curl -X POST http://localhost:4000/profile/avatar \
418
+ -H "Authorization: Bearer my_token" \
419
+ -F "user_id=10" \
420
+ -F "avatar=@/path/to/my_photo.jpg"
612
421
  ```
613
422
 
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` |
423
+ ---
623
424
 
624
- ### Built-in rate limiting
425
+ ### 2. Raw Binary Upload (Image / PDF / Octet-Stream)
625
426
 
626
- Protect your server from flooding and abusive clients with the built-in sliding-window rate limiter:
427
+ Direct raw byte streaming in request body (no multipart form):
627
428
 
628
429
  ```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
- )
430
+ api.post('/documents/raw') do |params|
431
+ file = params[:_raw_binary] # FilePayload instance
432
+
433
+ file.save_to("./storage/docs/#{file.filename}")
434
+
435
+ {
436
+ format: "raw binary",
437
+ detected_filename: file.filename,
438
+ size_bytes: file.size,
439
+ mime_type: file.content_type,
440
+ initial_hex: file.to_hex[0..30]
441
+ }
442
+ end
634
443
  ```
635
444
 
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 }
445
+ #### cURL raw binary:
446
+ ```bash
447
+ curl -X POST http://localhost:4000/documents/raw \
448
+ -H "Authorization: Bearer my_token" \
449
+ -H "Content-Type: application/pdf" \
450
+ -H "Content-Disposition: attachment; filename=\"contract.pdf\"" \
451
+ --data-binary @contract.pdf
640
452
  ```
641
453
 
642
- Response headers are automatically added to every request:
454
+ ---
643
455
 
644
- ```
645
- X-RateLimit-Limit: 100
646
- X-RateLimit-Remaining: 47
647
- Retry-After: 60 (only on 429 responses)
648
- X-RateLimit-Reset: 1721620800
456
+ ### 3. Base64 and Hexadecimal Conversion
457
+ ```ruby
458
+ api.post('/files/convert') do |params|
459
+ file = params[:_files][:file]
460
+
461
+ {
462
+ base64: file.to_base64, # Clean Base64 string without newlines
463
+ hexadecimal: file.to_hex, # Lowercase hex string
464
+ total_bytes: file.size
465
+ }
466
+ end
649
467
  ```
650
468
 
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:**
469
+ ---
654
470
 
471
+ ### 4. Plain Text Upload (`text/plain`)
655
472
  ```ruby
656
- # Public API — moderate protection
657
- GRApiManager::Server.new(rate_limit: 200, rate_limit_window: 60)
473
+ api.post('/logs/text') do |params|
474
+ raw_text = params[:_raw_text] # UTF-8 String
475
+ { lines: raw_text.lines.count, characters: raw_text.length }
476
+ end
477
+ ```
658
478
 
659
- # Strict — auth endpoints, login, password reset
660
- GRApiManager::Server.new(rate_limit: 10, rate_limit_window: 60)
479
+ ---
661
480
 
662
- # Generous internal service behind a trusted proxy
663
- GRApiManager::Server.new(rate_limit: 2000, rate_limit_window: 60)
664
- ```
481
+ ## Serving & Downloading Files to Clients
665
482
 
666
- ### Production architecture for truly massive traffic
483
+ When a route block returns a `String`, `gr_api_manager` serves it **directly as raw data**, allowing seamless downloads of images, PDFs, or binary streams:
667
484
 
668
- For thousands of concurrent users, add layers in front of the framework:
485
+ ```ruby
486
+ api.get('/downloads/photo/:id', auth: false) do |params|
487
+ photo_path = "./storage/avatars/user_#{params[:id]}.jpg"
488
+
489
+ unless File.exist?(photo_path)
490
+ status 404
491
+ next { error: "Photo not found" }
492
+ end
669
493
 
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)
494
+ # Configure response headers
495
+ content_type 'image/jpeg'
496
+ headers 'Content-Disposition' => "inline; filename=\"photo_#{params[:id]}.jpg\""
497
+
498
+ # Return binary bytes directly
499
+ File.binread(photo_path)
500
+ end
679
501
  ```
680
502
 
681
- **Minimal Nginx config for load balancing:**
503
+ ---
682
504
 
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
- }
505
+ ## Rate Limiting & Real IP Detection
689
506
 
690
- server {
691
- listen 80;
692
- server_name api.yourdomain.com;
507
+ Protect your API with thread-safe sliding-window rate limiting per client IP:
693
508
 
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
- }
509
+ ```ruby
510
+ api = GRApiManager::Server.new(
511
+ rate_limit: 60, # Max 60 requests
512
+ rate_limit_window: 60, # per 60-second window
513
+ trust_proxy_headers: true # Reads CF-Connecting-IP, X-Real-IP, X-Forwarded-For
514
+ )
701
515
  ```
702
516
 
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
- ```
517
+ Standard HTTP response headers:
518
+ * `X-RateLimit-Limit`: Maximum allowed requests (`60`).
519
+ * `X-RateLimit-Remaining`: Remaining requests in current window.
520
+ * `X-RateLimit-Reset`: Unix timestamp when quota resets.
521
+ * `Retry-After`: Seconds to wait if rate-limited (`429 Too Many Requests`).
717
522
 
718
523
  ---
719
524
 
720
- ## Multiple Route Groups on One Server
525
+ ## Smart Parameter Casting
721
526
 
527
+ URL and Query String parameters are automatically converted to native Ruby types before entering your route block:
722
528
 
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
529
+ ```ruby
530
+ api.get('/analytics') do |params|
531
+ # Request: /analytics?id=123&active=true&discount=15.5&balance=-500&category=tech
532
+
533
+ params[:id] # => 123 (Integer)
534
+ params[:active] # => true (TrueClass)
535
+ params[:discount] # => 15.5 (Float)
536
+ params[:balance] # => -500 (Integer)
537
+ params[:category] # => "tech" (String)
538
+
539
+ { status: "ok" }
540
+ end
734
541
  ```
735
542
 
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
543
+ ---
742
544
 
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
545
+ ## Responses, HTTP Status Codes & Dev Mode
747
546
 
748
- api.delete('/users/:id', auth: true) do |params|
749
- { message: "User #{params[:id]} deleted" }
750
- end
547
+ ### Custom status codes:
548
+ ```ruby
549
+ api.post('/resources') do
550
+ status 201 # Created
551
+ { message: "Resource created" }
751
552
  end
752
553
  ```
753
554
 
555
+ ### Developer Mode (`dev_mode: true`):
556
+ Enable `dev_mode: true` to receive structured JSON error payloads with full stack traces during development:
557
+
754
558
  ```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" }
559
+ api = GRApiManager::Server.new(dev_mode: true)
560
+ ```
561
+
562
+ Error response on 500:
563
+ ```json
564
+ {
565
+ "error": "Internal Server Error",
566
+ "details": "undefined local variable or method 'missing_var'",
567
+ "class": "NameError",
568
+ "backtrace": [
569
+ "/app/routes/users.rb:14:in `block in setup'",
570
+ "/lib/gr_api_manager.rb:482:in `instance_exec'"
760
571
  ]
572
+ }
573
+ ```
761
574
 
762
- api.get('/countries', auth: false) do
763
- { total: COUNTRIES.size, countries: COUNTRIES }
764
- end
575
+ ---
765
576
 
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
- ```
577
+ ## Concurrency & Production Deployment
773
578
 
579
+ ### Running with Puma in Production:
774
580
  ```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
581
+ # Start with 4 worker processes and 4:16 threads per worker
582
+ api.run!(workers: 4, threads: '4:16')
786
583
  ```
787
584
 
788
- ```ruby
789
- # main.rb — one server, three route groups, one process
790
- require 'gr_api_manager'
791
- require_relative 'routes/users'
792
- require_relative 'routes/countries'
793
- require_relative 'routes/products'
585
+ ### Production `Dockerfile`:
586
+ ```dockerfile
587
+ FROM ruby:3.3-slim
794
588
 
795
- api = GRApiManager::Server.new(
796
- port: 4567,
797
- bearer_token: "secret",
798
- prefix: "/api/v1"
799
- )
589
+ WORKDIR /app
590
+ COPY Gemfile* ./
591
+ RUN bundle install --without development test
800
592
 
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
593
+ COPY . .
804
594
 
805
- api.run!
806
- # All routes available on a single process at localhost:4567
595
+ EXPOSE 4000
596
+ CMD ["ruby", "app.rb"]
807
597
  ```
808
598
 
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
599
+ ---
814
600
 
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
- ```
601
+ ## Automated Testing
602
+
603
+ Full test suite with **RSpec** and **Rack::Test**:
820
604
 
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.
605
+ ```bash
606
+ rspec
607
+ # => 59 examples, 0 failures
608
+ ```
822
609
 
823
610
  ---
824
611
 
825
612
  ## License
826
613
 
827
- Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
614
+ This project is licensed under the [MIT](LICENSE) License. Created by **Gabo Razo**.