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.
- checksums.yaml +4 -4
- data/README.md +424 -637
- data/README_ES.md +615 -0
- data/lib/gr_api_manager.rb +449 -138
- metadata +60 -13
data/README.md
CHANGED
|
@@ -1,827 +1,614 @@
|
|
|
1
1
|
# GR API Manager
|
|
2
2
|
|
|
3
|
-
[](https://rubygems.org/gems/gr_api_manager)
|
|
4
|
+
[](https://rubygems.org/gems/gr_api_manager)
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](https://sinatrarb.com/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://rubygems.org/gems/gr_api_manager)
|
|
9
|
+
[](spec/)
|
|
10
|
+
[](README_ES.md)
|
|
5
11
|
|
|
6
|
-
|
|
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
|
-
##
|
|
16
|
+
## System Requirements
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
##
|
|
60
|
+
## Table of Contents
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
## Quickstart
|
|
85
77
|
|
|
86
|
-
|
|
87
|
-
PORT=4567
|
|
88
|
-
API_TOKEN=your_secret_token
|
|
89
|
-
```
|
|
78
|
+
Create an `app.rb` file:
|
|
90
79
|
|
|
91
80
|
```ruby
|
|
92
|
-
|
|
93
|
-
```
|
|
81
|
+
require 'gr_api_manager'
|
|
94
82
|
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
115
|
+
### 1. Static Bearer Token
|
|
137
116
|
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
121
|
+
api = GRApiManager::Server.new(
|
|
122
|
+
bearer_token: "my_secret_company_token_2026"
|
|
123
|
+
)
|
|
143
124
|
|
|
144
|
-
#
|
|
145
|
-
api.
|
|
146
|
-
status
|
|
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
|
-
#
|
|
151
|
-
api.get('/
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
199
|
-
|
|
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:
|
|
221
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
278
|
-
api.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
require 'base64'
|
|
200
|
+
## Multi-File Modular Architecture
|
|
320
201
|
|
|
321
|
-
|
|
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
|
-
```
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
###
|
|
337
|
-
|
|
216
|
+
### Module 1: `routes/auth_routes.rb`
|
|
338
217
|
```ruby
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
###
|
|
348
|
-
|
|
233
|
+
### Module 2: `routes/admin_routes.rb`
|
|
349
234
|
```ruby
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
###
|
|
358
|
-
|
|
248
|
+
### Module 3: `routes/files_routes.rb`
|
|
359
249
|
```ruby
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
{
|
|
404
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
#
|
|
492
|
-
|
|
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
|
-
{
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
#
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
#
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
#
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
##
|
|
351
|
+
## Declarative Schema & Type Validation
|
|
578
352
|
|
|
579
|
-
|
|
353
|
+
The `requires:` option validates data types, string formats, uploaded files, and custom logic:
|
|
580
354
|
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
391
|
+
---
|
|
596
392
|
|
|
597
|
-
|
|
393
|
+
## Comprehensive File, Binary & Image Handling
|
|
598
394
|
|
|
599
|
-
|
|
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.
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
609
|
-
|
|
415
|
+
#### cURL multipart:
|
|
610
416
|
```bash
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
425
|
+
### 2. Raw Binary Upload (Image / PDF / Octet-Stream)
|
|
625
426
|
|
|
626
|
-
|
|
427
|
+
Direct raw byte streaming in request body (no multipart form):
|
|
627
428
|
|
|
628
429
|
```ruby
|
|
629
|
-
api
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
454
|
+
---
|
|
643
455
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
**Common rate limit configs:**
|
|
469
|
+
---
|
|
654
470
|
|
|
471
|
+
### 4. Plain Text Upload (`text/plain`)
|
|
655
472
|
```ruby
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
660
|
-
GRApiManager::Server.new(rate_limit: 10, rate_limit_window: 60)
|
|
479
|
+
---
|
|
661
480
|
|
|
662
|
-
|
|
663
|
-
GRApiManager::Server.new(rate_limit: 2000, rate_limit_window: 60)
|
|
664
|
-
```
|
|
481
|
+
## Serving & Downloading Files to Clients
|
|
665
482
|
|
|
666
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
503
|
+
---
|
|
682
504
|
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
763
|
-
{ total: COUNTRIES.size, countries: COUNTRIES }
|
|
764
|
-
end
|
|
575
|
+
---
|
|
765
576
|
|
|
766
|
-
|
|
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
|
-
#
|
|
776
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
prefix: "/api/v1"
|
|
799
|
-
)
|
|
589
|
+
WORKDIR /app
|
|
590
|
+
COPY Gemfile* ./
|
|
591
|
+
RUN bundle install --without development test
|
|
800
592
|
|
|
801
|
-
|
|
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
|
-
|
|
806
|
-
|
|
595
|
+
EXPOSE 4000
|
|
596
|
+
CMD ["ruby", "app.rb"]
|
|
807
597
|
```
|
|
808
598
|
|
|
809
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
605
|
+
```bash
|
|
606
|
+
rspec
|
|
607
|
+
# => 59 examples, 0 failures
|
|
608
|
+
```
|
|
822
609
|
|
|
823
610
|
---
|
|
824
611
|
|
|
825
612
|
## License
|
|
826
613
|
|
|
827
|
-
|
|
614
|
+
This project is licensed under the [MIT](LICENSE) License. Created by **Gabo Razo**.
|