GR API Manager
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.
Features
- Available on RubyGems — install globally, use anywhere.
- Route-level Bearer Token auth (on/off per endpoint).
- Declarative required parameter validation → automatic
400 Bad Request. - Smart type casting:
"10"→Integer,"true"→TrueClass,"9.5"→Float. - Global JSON error responses for
404,500, and413. - Broad CORS and preflight
OPTIONSout of the box. - API versioning via configurable route prefix (
/api/v1,/geo/v2, etc.). - Full binary & file support — multipart, raw binary, Base64, hex,
text/plain. - Configurable body size limit with
mb/gbhelpers. - Dev mode — full stack traces on
500for easier debugging.
Installation
gem 'gr-api-manager' # Gemfile
bundle install
# or: gem install gr-api-manager
Quick Start — name it anything you want
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.
require 'gr_api_manager'
# Any name works
api = GRApiManager::Server.new(port: 4567, bearer_token: "secret")
pais_api = GRApiManager::Server.new(prefix: "/geo/v1")
reports = GRApiManager::Server.new(port: 5000, max_body_size: GRApiManager.gb(1))
api.get('/health', auth: false) { { status: 'ok' } }
api.run!
Configuration
GRApiManager::Server.new(
port: 4567,
bearer_token: "secret",
permitted_hosts: ["example.com"], # empty = allow all
prefix: "/api/v1",
max_body_size: GRApiManager.mb(50), # default 50 MB
dev_mode: false # default false
)
Size helpers
Express limits cleanly without doing manual math:
GRApiManager.mb(50) # 50 megabytes
GRApiManager.mb(200) # 200 megabytes — for large file uploads
GRApiManager.gb(1) # 1 gigabyte — for heavy reports or video
# Examples
video_api = GRApiManager::Server.new(max_body_size: GRApiManager.gb(2))
json_api = GRApiManager::Server.new(max_body_size: GRApiManager.mb(1))
report_api = GRApiManager::Server.new(max_body_size: GRApiManager.mb(500))
.env file (recommended for production)
PORT=4567
API_TOKEN=your_secret_token
api = GRApiManager::Server.new # picks up PORT and API_TOKEN automatically
Explicit arguments always override .env. Add .env to .gitignore.
HTTP Verbs — what each one does
| Method | Purpose | Typical use |
|---|---|---|
GET |
Read — fetch data, no side effects | List, search, get by ID |
POST |
Create — add a new resource | Create user, upload file |
PUT |
Full replace — replace an existing resource entirely | Update full user profile |
PATCH |
Partial update — modify specific fields only | Change only an email |
DELETE |
Remove — delete a resource | Delete user, remove file |
api.get('/users') { ... } # list
api.get('/users/:id') { ... } # read one
api.post('/users') { ... } # create
api.put('/users/:id') { ... } # full replace
api.patch('/users/:id') { ... } # partial update
api.delete('/users/:id') { ... } # delete
Defining Routes
api.post('/path', auth: true, requires: [:name, :email]) do |params|
# params — merged hash: URL segments + query string (type-cast) + parsed body
{ result: "ok" }
end
| Option | Type | Default | Description |
|---|---|---|---|
auth |
Boolean | true |
Require Bearer Token |
requires |
Array | [] |
Required parameter keys |
Success Responses
Return any Hash or Array — the framework serializes to JSON automatically.
# Simple hash
api.get('/ping', auth: false) { { pong: true } }
# Set a status code
api.post('/users', requires: [:name]) do |params|
status 201
{ message: "Created", user: { name: params[:name] } }
end
# Short-circuit with next
api.get('/users/:id') do |params|
if params[:id] == 0
status 404
next { error: "Not found" }
end
{ id: params[:id], name: "Alice" }
end
Returning a
Stringsends it as-is (no JSON wrapping) — useful for binary file responses.
Authentication
api.get('/health', auth: false) { { status: 'online' } } # public
api.get('/data') do |params| # protected (default)
{ secret: "data" }
end
curl -H "Authorization: Bearer your_token" http://localhost:4567/api/v1/data
// 401 — missing header
{ "error": "Token required. Format: 'Bearer <token>'" }
// 403 — wrong token
{ "error": "Invalid token" }
Parameter Validation
api.post('/users', requires: [:name, :email, :role]) do |params|
status 201
{ message: "User created", user: params }
end
curl -X POST http://localhost:4567/api/v1/users \
-H "Authorization: Bearer secret" \
-H "Content-Type: application/json" \
-d '{"name":"Gabo"}'
# => 400 { "error": "Missing required parameters", "required": ["email", "role"] }
Smart Type Casting
Query string values are cast to native Ruby types before reaching your block:
| String | Ruby type | Value |
|---|---|---|
"42" |
Integer | 42 |
"3.14" |
Float | 3.14 |
"true" |
TrueClass | true |
"false" |
FalseClass | false |
curl "http://localhost:4567/api/v1/products?page=2&active=true&price=9.99"
# params => { page: 2, active: true, price: 9.99 }
File & Binary Body Handling
The framework auto-detects Content-Type and parses accordingly. No extra setup needed.
Supported body formats
| Content-Type | What you get in params |
|---|---|
application/json |
Regular symbolized hash |
multipart/form-data |
Text fields + :_files → { field: FilePayload } |
image/*, video/*, audio/* |
:_raw_binary → FilePayload |
application/pdf, application/msword, application/vnd.* |
:_raw_binary → FilePayload |
application/octet-stream |
:_raw_binary → FilePayload |
text/plain |
:_raw_text → String |
FilePayload API
| Method | Returns | Description |
|---|---|---|
#read |
String (binary) |
Raw bytes |
#to_base64 |
String |
Base64-encoded (no newlines) |
#to_hex |
String |
Lowercase hexadecimal string |
#save_to(path) |
String |
Save to disk, returns path |
#filename |
String |
Original filename |
#content_type |
String |
MIME type |
#size |
Integer |
Size in bytes |
#extension |
String |
Extension (.jpg, .pdf, etc.) |
#to_h |
Hash |
JSON-safe summary |
Multipart image upload
api.post('/upload/avatar', auth: true) do |params|
file = params[:_files][:avatar]
halt 400, { error: 'Field "avatar" required' }.to_json unless file
halt 400, { error: "Invalid type" }.to_json unless %w[.jpg .png .webp].include?(file.extension)
file.save_to("/uploads/#{file.filename}")
status 201
{ message: "Uploaded", file: file.to_h }
end
curl -X POST http://localhost:4567/api/v1/upload/avatar \
-H "Authorization: Bearer secret" \
-F "avatar=@photo.jpg"
Multiple files in one request
api.post('/documents', auth: true) do |params|
files = params[:_files] || {}
halt 400, { error: 'No files received' }.to_json if files.empty?
saved = files.map { |_, f| f.save_to("/uploads/#{f.filename}"); f.to_h }
status 201
{ uploaded: saved }
end
curl -X POST http://localhost:4567/api/v1/documents \
-H "Authorization: Bearer secret" \
-F "doc=@report.pdf" -F "thumbnail=@thumb.png"
Raw binary body (image, PDF, Word, etc.)
api.post('/files/raw', auth: true) do |params|
file = params[:_raw_binary]
halt 400, { error: 'Binary body required' }.to_json unless file
path = file.save_to("/uploads/#{file.filename}")
{ saved_to: path, magic_bytes: file.to_hex[0, 8], size: file.size }
end
curl -X POST http://localhost:4567/api/v1/files/raw \
-H "Authorization: Bearer secret" \
-H "Content-Type: image/png" --data-binary @photo.png
curl -X POST http://localhost:4567/api/v1/files/raw \
-H "Authorization: Bearer secret" \
-H "Content-Type: application/pdf" --data-binary @doc.pdf
Base64 inside JSON
require 'base64'
api.post('/files/base64', auth: true, requires: [:data, :filename]) do |params|
raw = Base64.strict_decode64(params[:data])
File.open("/uploads/#{params[:filename]}", 'wb') { |f| f.write(raw) }
{ message: "Saved", bytes: raw.bytesize }
end
BASE64=$(base64 -w0 photo.jpg)
curl -X POST http://localhost:4567/api/v1/files/base64 \
-H "Authorization: Bearer secret" \
-H "Content-Type: application/json" \
-d "{\"filename\":\"photo.jpg\",\"data\":\"$BASE64\"}"
Hexadecimal
api.post('/files/tohex', auth: true) do |params|
file = params[:_raw_binary]
halt 400, { error: 'Binary body required' }.to_json unless file
# to_hex converts any binary to a lowercase hex string
{ hex: file.to_hex, bytes: file.size }
end
Plain text body
api.post('/notes', auth: true) do |params|
text = params[:_raw_text]
halt 400, { error: 'Empty body' }.to_json if text.nil? || text.strip.empty?
{ received: text, words: text.split.size }
end
Serving a file as binary response
api.get('/files/:name', auth: true) do |params|
path = "/uploads/#{File.basename(params[:name].to_s)}"
halt 404, { error: "Not found" }.to_json unless File.exist?(path)
content_type 'application/octet-stream'
response.headers['Content-Disposition'] = "attachment; filename=\"#{File.basename(path)}\""
File.binread(path) # String → sent as-is, no JSON wrapping
end
Body size limit
// When exceeded → 413
{ "error": "Payload too large", "max_bytes": 52428800 }
GRApiManager::Server.new(max_body_size: GRApiManager.mb(200)) # 200 MB
GRApiManager::Server.new(max_body_size: GRApiManager.gb(2)) # 2 GB
GRApiManager::Server.new(max_body_size: GRApiManager.mb(1)) # 1 MB
Dev Mode
Enable during development for full stack traces on 500 errors:
api = GRApiManager::Server.new(
bearer_token: "secret",
dev_mode: true # ⚠️ Disable in production
)
Production (dev_mode: false):
{ "error": "Internal Server Error", "details": "undefined method 'foo' for nil" }
Development (dev_mode: true):
{
"error": "Internal Server Error",
"details": "undefined method 'foo' for nil",
"class": "NoMethodError",
"backtrace": ["app.rb:42:in 'block in register_route'", "..."]
}
Error Handling
// 404
{ "error": "Endpoint not found", "path": "/api/v1/missing" }
// 500
{ "error": "Internal Server Error", "details": "..." }
// 413
{ "error": "Payload too large", "max_bytes": 52428800 }
Request Logging
[14:32:01] GET /api/v1/health - 200
[14:32:05] POST /api/v1/users - 400
[14:32:10] POST /api/v1/upload/avatar - 201
Green for 2xx, red for everything else.
Running the Server
=============================================
GR API MANAGER STARTED
Port : 4567
Auth : Enabled
Prefix : /api/v1
Max Body : 50.0 MB
Dev Mode : Off
=============================================
Complete Example — Country API
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.
require 'gr_api_manager'
require 'fileutils'
FileUtils.mkdir_p('/uploads/flags')
pais_api = GRApiManager::Server.new(
port: 4567,
bearer_token: "geo_secret_2024",
prefix: "/geo/v1",
max_body_size: GRApiManager.mb(5),
dev_mode: true
)
COUNTRIES = [
{ id: 1, name: "Mexico", capital: "Mexico City", pop: 128_000_000, continent: "North America" },
{ id: 2, name: "Argentina", capital: "Buenos Aires", pop: 45_000_000, continent: "South America" },
{ id: 3, name: "Spain", capital: "Madrid", pop: 47_000_000, continent: "Europe" }
]
# GET — list all (public)
pais_api.get('/countries', auth: false) do
{ total: COUNTRIES.size, countries: COUNTRIES }
end
# GET — single country (:id cast to Integer automatically)
pais_api.get('/countries/:id', auth: false) do |params|
c = COUNTRIES.find { |x| x[:id] == params[:id] }
status 404 and next { error: "Not found" } unless c
c
end
# GET — search by name
pais_api.get('/countries/search', auth: false, requires: [:name]) do |params|
results = COUNTRIES.select { |c| c[:name].downcase.include?(params[:name].downcase) }
{ total: results.size, countries: results }
end
# POST — create (protected)
pais_api.post('/countries', auth: true, requires: [:name, :capital, :pop]) do |params|
c = { id: COUNTRIES.size + 1, name: params[:name],
capital: params[:capital], pop: params[:pop],
continent: params[:continent] || "Unknown" }
COUNTRIES << c
status 201
{ message: "Country created", country: c }
end
# PUT — full replace (protected)
pais_api.put('/countries/:id', auth: true, requires: [:name, :capital]) do |params|
c = COUNTRIES.find { |x| x[:id] == params[:id] }
status 404 and next { error: "Not found" } unless c
c[:name] = params[:name]; c[:capital] = params[:capital]
c[:pop] = params[:pop] if params[:pop]
{ message: "Updated", country: c }
end
# PATCH — partial update (protected)
pais_api.patch('/countries/:id', auth: true) do |params|
c = COUNTRIES.find { |x| x[:id] == params[:id] }
status 404 and next { error: "Not found" } unless c
[:name, :capital, :pop, :continent].each { |k| c[k] = params[k] if params[k] }
{ message: "Patched", country: c }
end
# DELETE (protected)
pais_api.delete('/countries/:id', auth: true) do |params|
c = COUNTRIES.find { |x| x[:id] == params[:id] }
status 404 and next { error: "Not found" } unless c
COUNTRIES.delete(c)
{ message: "Deleted", id: params[:id] }
end
# POST — upload flag (multipart, protected)
pais_api.post('/countries/:id/flag', auth: true) do |params|
file = params[:_files]&.dig(:flag)
halt 400, { error: 'Field "flag" required' }.to_json unless file
halt 400, { error: "Format not allowed: #{file.extension}" }.to_json \
unless %w[.jpg .jpeg .png .svg .webp].include?(file.extension)
file.save_to("/uploads/flags/#{params[:id]}#{file.extension}")
status 201
{ message: "Flag uploaded", file: file.to_h }
end
# GET — download flag (public, binary response)
pais_api.get('/countries/:id/flag', auth: false) do |params|
f = Dir.glob("/uploads/flags/#{params[:id]}.*").first
halt 404, { error: "Flag not found" }.to_json unless f
content_type 'application/octet-stream'
response.headers['Content-Disposition'] = "attachment; filename=\"#{File.basename(f)}\""
File.binread(f)
end
pais_api.run!
curl http://localhost:4567/geo/v1/countries
curl http://localhost:4567/geo/v1/countries/1
curl -X POST http://localhost:4567/geo/v1/countries \
-H "Authorization: Bearer geo_secret_2024" \
-H "Content-Type: application/json" \
-d '{"name":"Brazil","capital":"Brasilia","pop":215000000}'
curl -X PATCH http://localhost:4567/geo/v1/countries/1 \
-H "Authorization: Bearer geo_secret_2024" \
-H "Content-Type: application/json" \
-d '{"pop":130000000}'
curl -X POST http://localhost:4567/geo/v1/countries/1/flag \
-H "Authorization: Bearer geo_secret_2024" \
-F "flag=@mexico_flag.png"
curl -X DELETE http://localhost:4567/geo/v1/countries/3 \
-H "Authorization: Bearer geo_secret_2024"
High Traffic & Concurrency
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.
How it handles concurrency
Puma uses a multi-worker + multi-thread model:
- Workers = OS processes, each with its own memory. More workers = more CPU cores used.
- Threads = lightweight concurrent handlers inside each worker. Threads share memory.
Request → Worker 1 → Thread A → route handler
→ Thread B → route handler
→ ...
→ Worker 2 → Thread A → route handler
→ ...
A server with workers: 4, threads: '2:8' can handle up to 32 simultaneous requests before queuing.
Configuring workers and threads
Pass them directly to run!:
api.run!(
workers: 4, # Number of Puma worker processes
threads: '2:8' # min_threads:max_threads per worker
)
Or via environment variables (recommended for production):
WEB_CONCURRENCY=4 ruby app.rb
Practical starting points:
| Scenario | Workers | Threads |
|---|---|---|
| Development / local | 1 | 1:4 |
| Small server (1–2 CPU cores) | 2 | 2:8 |
| Medium server (4 CPU cores) | 4 | 2:8 |
| High-traffic (8+ CPU cores) | 8 | 4:16 |
| I/O-heavy (DB, external APIs) | 2 | 8:32 |
Built-in rate limiting
Protect your server from flooding and abusive clients with the built-in sliding-window rate limiter:
api = GRApiManager::Server.new(
bearer_token: "secret",
rate_limit: 100, # max 100 requests per IP
rate_limit_window: 60 # per 60-second window
)
When a client exceeds the limit, they receive 429 Too Many Requests:
{ "error": "Too many requests", "retry_after_seconds": 60 }
Response headers are automatically added to every request:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 47
Retry-After: 60 (only on 429 responses)
X-RateLimit-Reset: 1721620800
The limiter is thread-safe (uses a Mutex) and runs a background cleanup thread automatically to prevent memory growth from tracked IPs.
Common rate limit configs:
# Public API — moderate protection
GRApiManager::Server.new(rate_limit: 200, rate_limit_window: 60)
# Strict — auth endpoints, login, password reset
GRApiManager::Server.new(rate_limit: 10, rate_limit_window: 60)
# Generous — internal service behind a trusted proxy
GRApiManager::Server.new(rate_limit: 2000, rate_limit_window: 60)
Production architecture for truly massive traffic
For thousands of concurrent users, add layers in front of the framework:
Internet
│
▼
[Nginx] ← terminates SSL, serves static files, load balances
│
├──▶ GR API Manager (worker 1, port 4567)
├──▶ GR API Manager (worker 2, port 4568)
└──▶ GR API Manager (worker 3, port 4569)
Minimal Nginx config for load balancing:
upstream gr_api {
server 127.0.0.1:4567;
server 127.0.0.1:4568;
server 127.0.0.1:4569;
}
server {
listen 80;
server_name api.yourdomain.com;
location / {
proxy_pass http://gr_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
What the banner shows
=============================================
GR API MANAGER STARTED
Port : 4567
Auth : Enabled
Prefix : /api/v1
Max Body : 50.0 MB
Workers : 4 | Threads: 2:8
Rate Limit: 100 req / 60s per IP
Dev Mode : Off
=============================================
Multiple Route Groups on One Server
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.
The cleanest pattern is to extract each group into its own file and pass the server object as an argument:
project/
├── main.rb
└── routes/
├── users.rb
├── countries.rb
└── products.rb
# routes/users.rb
def register_users(api)
api.get('/users', auth: false) do
{ users: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] }
end
api.post('/users', auth: true, requires: [:name, :email]) do |params|
status 201
{ message: "User created", user: { name: params[:name], email: params[:email] } }
end
api.delete('/users/:id', auth: true) do |params|
{ message: "User #{params[:id]} deleted" }
end
end
# routes/countries.rb
def register_countries(api)
COUNTRIES = [
{ id: 1, name: "Mexico", capital: "Mexico City" },
{ id: 2, name: "Spain", capital: "Madrid" }
]
api.get('/countries', auth: false) do
{ total: COUNTRIES.size, countries: COUNTRIES }
end
api.get('/countries/:id', auth: false) do |params|
c = COUNTRIES.find { |x| x[:id] == params[:id] }
status 404 and next { error: "Not found" } unless c
c
end
end
# routes/products.rb
def register_products(api)
api.get('/products', auth: false) do
{ products: [{ id: 1, name: "Laptop", price: 999.99 }] }
end
api.post('/products', auth: true, requires: [:name, :price]) do |params|
status 201
{ message: "Product created", product: { name: params[:name], price: params[:price] } }
end
end
# main.rb — one server, three route groups, one process
require 'gr_api_manager'
require_relative 'routes/users'
require_relative 'routes/countries'
require_relative 'routes/products'
api = GRApiManager::Server.new(
port: 4567,
bearer_token: "secret",
prefix: "/api/v1"
)
register_users(api) # mounts: GET/POST /api/v1/users, DELETE /api/v1/users/:id
register_countries(api) # mounts: GET /api/v1/countries, GET /api/v1/countries/:id
register_products(api) # mounts: GET/POST /api/v1/products
api.run!
# All routes available on a single process at localhost:4567
# All three groups work on the same server
curl http://localhost:4567/api/v1/users
curl http://localhost:4567/api/v1/countries
curl http://localhost:4567/api/v1/products
curl -X POST http://localhost:4567/api/v1/users \
-H "Authorization: Bearer secret" \
-H "Content-Type: application/json" \
-d '{"name":"Carlos","email":"carlos@example.com"}'
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.
License
Available as open source under the MIT License.