gr_api_manager 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +645 -163
- data/lib/gr_api_manager.rb +448 -52
- metadata +21 -4
data/lib/gr_api_manager.rb
CHANGED
|
@@ -1,34 +1,334 @@
|
|
|
1
1
|
require 'sinatra/base'
|
|
2
2
|
require 'json'
|
|
3
3
|
require 'dotenv/load'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'tempfile'
|
|
4
6
|
|
|
5
7
|
module GRApiManager
|
|
8
|
+
# Convenience size helpers — use in max_body_size:
|
|
9
|
+
# GRApiManager.mb(50) => 50 MB in bytes
|
|
10
|
+
# GRApiManager.gb(2) => 2 GB in bytes
|
|
11
|
+
def self.mb(n) = n * 1_024 * 1_024
|
|
12
|
+
def self.gb(n) = n * 1_024 * 1_024 * 1_024
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# RateLimiter — thread-safe sliding-window rate limiter per IP address.
|
|
16
|
+
#
|
|
17
|
+
# Prevents any single client from flooding the server. Uses a mutex-protected
|
|
18
|
+
# in-memory store with automatic cleanup to avoid unbounded memory growth.
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
class RateLimiter
|
|
21
|
+
attr_reader :max_requests, :window_seconds
|
|
22
|
+
|
|
23
|
+
def initialize(max_requests:, window_seconds:)
|
|
24
|
+
@max = max_requests
|
|
25
|
+
@window = window_seconds
|
|
26
|
+
@store = Hash.new { |h, k| h[k] = [] }
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns true if the request from *ip* is within the allowed rate.
|
|
31
|
+
# Increments the counter for that IP on each allowed request.
|
|
32
|
+
def allow?(ip)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
now = Time.now.to_f
|
|
35
|
+
cutoff = now - @window
|
|
36
|
+
@store[ip].reject! { |t| t < cutoff }
|
|
37
|
+
return false if @store[ip].size >= @max
|
|
38
|
+
@store[ip] << now
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Remaining requests allowed for *ip* in the current window.
|
|
44
|
+
def remaining(ip)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
cutoff = Time.now.to_f - @window
|
|
47
|
+
active = @store[ip].count { |t| t >= cutoff }
|
|
48
|
+
[@max - active, 0].max
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Removes stale entries — call periodically to prevent memory growth.
|
|
53
|
+
def cleanup!
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
cutoff = Time.now.to_f - @window
|
|
56
|
+
@store.each_value { |times| times.reject! { |t| t < cutoff } }
|
|
57
|
+
@store.delete_if { |_, times| times.empty? }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Summary hash — safe to log or expose on a diagnostic endpoint.
|
|
62
|
+
def stats
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
{ tracked_ips: @store.size, max_requests: @max, window_seconds: @window }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# FilePayload — wraps an uploaded file (multipart or raw) with a clean API.
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
class FilePayload
|
|
73
|
+
attr_reader :filename, :content_type, :size, :tempfile
|
|
74
|
+
|
|
75
|
+
def initialize(tempfile:, filename:, content_type:)
|
|
76
|
+
@tempfile = tempfile
|
|
77
|
+
@filename = filename.to_s
|
|
78
|
+
@content_type = content_type.to_s
|
|
79
|
+
@size = tempfile.respond_to?(:size) ? tempfile.size : tempfile.length
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns the raw binary content of the file as a String (encoding: BINARY).
|
|
83
|
+
def read
|
|
84
|
+
if @tempfile.respond_to?(:read)
|
|
85
|
+
@tempfile.rewind
|
|
86
|
+
@tempfile.read
|
|
87
|
+
else
|
|
88
|
+
@tempfile.to_s.force_encoding(Encoding::BINARY)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the file content encoded as a Base64 string (no newlines).
|
|
93
|
+
def to_base64
|
|
94
|
+
Base64.strict_encode64(read)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns the file content encoded as a lowercase hexadecimal string.
|
|
98
|
+
def to_hex
|
|
99
|
+
read.unpack1('H*')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Saves the uploaded content to *dest_path* on disk. Returns dest_path.
|
|
103
|
+
def save_to(dest_path)
|
|
104
|
+
File.open(dest_path, 'wb') { |f| f.write(read) }
|
|
105
|
+
dest_path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Convenience: the file extension derived from the original filename.
|
|
109
|
+
def extension
|
|
110
|
+
File.extname(@filename).downcase
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Human-friendly summary — safe to include in JSON responses.
|
|
114
|
+
def to_h
|
|
115
|
+
{
|
|
116
|
+
filename: @filename,
|
|
117
|
+
content_type: @content_type,
|
|
118
|
+
size: @size,
|
|
119
|
+
extension: extension
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def inspect
|
|
124
|
+
"#<GRApiManager::FilePayload filename=#{@filename.inspect} " \
|
|
125
|
+
"content_type=#{@content_type.inspect} size=#{@size}>"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# BodyParser — detects Content-Type and returns an appropriate parsed result.
|
|
131
|
+
#
|
|
132
|
+
# Supported formats:
|
|
133
|
+
# application/json -> Hash (symbolized keys)
|
|
134
|
+
# multipart/form-data -> Hash + :_files key (FilePayload objects)
|
|
135
|
+
# application/octet-stream -> :_raw_binary (FilePayload)
|
|
136
|
+
# image/* / application/pdf etc. -> :_raw_binary (FilePayload)
|
|
137
|
+
# text/plain -> :_raw_text (String)
|
|
138
|
+
# application/x-www-form-urlencoded-> Hash (Sinatra already handles this)
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
module BodyParser
|
|
141
|
+
|
|
142
|
+
BINARY_MIME_PREFIXES = %w[
|
|
143
|
+
image/ video/ audio/ application/pdf application/msword
|
|
144
|
+
application/vnd. application/zip application/x-tar
|
|
145
|
+
application/x-rar application/octet-stream
|
|
146
|
+
].freeze
|
|
147
|
+
|
|
148
|
+
# Returns a Hash of parsed body values.
|
|
149
|
+
# Special keys injected into the result hash:
|
|
150
|
+
# :_files => { field_name => FilePayload } (multipart)
|
|
151
|
+
# :_raw_binary => FilePayload (raw binary body)
|
|
152
|
+
# :_raw_text => String (plain-text body)
|
|
153
|
+
def self.parse(request)
|
|
154
|
+
content_type = (request.content_type || '').split(';').first.strip.downcase
|
|
155
|
+
|
|
156
|
+
case content_type
|
|
157
|
+
when 'application/json'
|
|
158
|
+
parse_json(request)
|
|
159
|
+
|
|
160
|
+
when 'multipart/form-data'
|
|
161
|
+
parse_multipart(request)
|
|
162
|
+
|
|
163
|
+
when 'application/x-www-form-urlencoded'
|
|
164
|
+
# Sinatra already exposes these in `params` — nothing extra to do.
|
|
165
|
+
{}
|
|
166
|
+
|
|
167
|
+
when 'text/plain'
|
|
168
|
+
text = request.body.read.to_s.force_encoding(Encoding::UTF_8)
|
|
169
|
+
{ _raw_text: text }
|
|
170
|
+
|
|
171
|
+
else
|
|
172
|
+
# Treat anything else that looks binary as a raw binary upload.
|
|
173
|
+
if binary_content_type?(content_type)
|
|
174
|
+
parse_raw_binary(request, content_type)
|
|
175
|
+
else
|
|
176
|
+
# Last resort: try JSON, silently fall back to empty hash.
|
|
177
|
+
parse_json(request) rescue {}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# -------------------------------------------------------------------------
|
|
183
|
+
private_class_method
|
|
184
|
+
|
|
185
|
+
def self.parse_json(request)
|
|
186
|
+
body = request.body.read.to_s
|
|
187
|
+
return {} if body.strip.empty?
|
|
188
|
+
JSON.parse(body, symbolize_names: true)
|
|
189
|
+
rescue JSON::ParserError => e
|
|
190
|
+
raise ArgumentError, "Invalid JSON body: #{e.message}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.parse_multipart(request)
|
|
194
|
+
result = {}
|
|
195
|
+
files = {}
|
|
196
|
+
|
|
197
|
+
request.params.each do |key, value|
|
|
198
|
+
sym = key.to_sym
|
|
199
|
+
|
|
200
|
+
if value.is_a?(Hash) && value.key?(:tempfile)
|
|
201
|
+
# Rack multipart file upload hash
|
|
202
|
+
files[sym] = FilePayload.new(
|
|
203
|
+
tempfile: value[:tempfile],
|
|
204
|
+
filename: value[:filename] || key,
|
|
205
|
+
content_type: value[:type] || 'application/octet-stream'
|
|
206
|
+
)
|
|
207
|
+
elsif value.is_a?(Array)
|
|
208
|
+
# Multiple file inputs with the same name
|
|
209
|
+
files[sym] = value.map do |v|
|
|
210
|
+
if v.is_a?(Hash) && v.key?(:tempfile)
|
|
211
|
+
FilePayload.new(
|
|
212
|
+
tempfile: v[:tempfile],
|
|
213
|
+
filename: v[:filename] || key,
|
|
214
|
+
content_type: v[:type] || 'application/octet-stream'
|
|
215
|
+
)
|
|
216
|
+
else
|
|
217
|
+
v
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
else
|
|
221
|
+
result[sym] = value
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
result[:_files] = files unless files.empty?
|
|
226
|
+
result
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def self.parse_raw_binary(request, content_type)
|
|
230
|
+
raw = request.body.read
|
|
231
|
+
return {} if raw.nil? || raw.empty?
|
|
232
|
+
|
|
233
|
+
# Try to derive a filename from the Content-Disposition header, if any.
|
|
234
|
+
disposition = request.env['HTTP_CONTENT_DISPOSITION'] || ''
|
|
235
|
+
filename = disposition[/filename="?([^";]+)"?/, 1] || "upload#{ext_for(content_type)}"
|
|
236
|
+
|
|
237
|
+
# Wrap the raw bytes in a StringIO so FilePayload can rewind/read it.
|
|
238
|
+
io = StringIO.new(raw.force_encoding(Encoding::BINARY))
|
|
239
|
+
io.define_singleton_method(:size) { raw.bytesize }
|
|
240
|
+
|
|
241
|
+
payload = FilePayload.new(
|
|
242
|
+
tempfile: io,
|
|
243
|
+
filename: filename,
|
|
244
|
+
content_type: content_type
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
{ _raw_binary: payload }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.binary_content_type?(ct)
|
|
251
|
+
BINARY_MIME_PREFIXES.any? { |prefix| ct.start_with?(prefix) }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Maps common MIME types to file extensions for unnamed raw uploads.
|
|
255
|
+
def self.ext_for(content_type)
|
|
256
|
+
{
|
|
257
|
+
'image/jpeg' => '.jpg',
|
|
258
|
+
'image/png' => '.png',
|
|
259
|
+
'image/gif' => '.gif',
|
|
260
|
+
'image/webp' => '.webp',
|
|
261
|
+
'image/svg+xml' => '.svg',
|
|
262
|
+
'image/bmp' => '.bmp',
|
|
263
|
+
'video/mp4' => '.mp4',
|
|
264
|
+
'video/webm' => '.webm',
|
|
265
|
+
'audio/mpeg' => '.mp3',
|
|
266
|
+
'audio/wav' => '.wav',
|
|
267
|
+
'application/pdf' => '.pdf',
|
|
268
|
+
'application/msword' => '.doc',
|
|
269
|
+
'application/zip' => '.zip',
|
|
270
|
+
'application/x-tar' => '.tar',
|
|
271
|
+
'application/octet-stream' => '.bin'
|
|
272
|
+
}.fetch(content_type, '.bin')
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Server — the public-facing DSL.
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
6
279
|
class Server
|
|
7
280
|
attr_reader :app_class
|
|
8
281
|
|
|
9
282
|
# Initializes the server configuration.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
283
|
+
#
|
|
284
|
+
# Options:
|
|
285
|
+
# port: Integer – listening port (default: ENV['PORT'] || 4000)
|
|
286
|
+
# bearer_token: String – Bearer token for auth (default: ENV['API_TOKEN'])
|
|
287
|
+
# permitted_hosts: Array – host allowlist; empty = allow all
|
|
288
|
+
# prefix: String – route prefix, e.g. '/api/v1'
|
|
289
|
+
# max_body_size: Integer – maximum accepted body in bytes (default: 50 MB)
|
|
290
|
+
# dev_mode: Boolean – show full stack traces on 500 (default: false)
|
|
291
|
+
# rate_limit: Integer – max requests per IP per window (nil = disabled)
|
|
292
|
+
# rate_limit_window: Integer – sliding window in seconds (default: 60)
|
|
293
|
+
def initialize(
|
|
294
|
+
port: nil,
|
|
295
|
+
bearer_token: nil,
|
|
296
|
+
permitted_hosts: [],
|
|
297
|
+
prefix: '',
|
|
298
|
+
max_body_size: GRApiManager.mb(50),
|
|
299
|
+
dev_mode: false,
|
|
300
|
+
rate_limit: nil,
|
|
301
|
+
rate_limit_window: 60
|
|
302
|
+
)
|
|
303
|
+
@port = port || ENV['PORT'] || 4000
|
|
304
|
+
@token = bearer_token || ENV['API_TOKEN']
|
|
13
305
|
@permitted_hosts = permitted_hosts.empty? ? [] : permitted_hosts
|
|
14
|
-
@prefix
|
|
15
|
-
|
|
306
|
+
@prefix = prefix
|
|
307
|
+
@max_body_size = max_body_size
|
|
308
|
+
@dev_mode = dev_mode
|
|
309
|
+
@rate_limiter = rate_limit ? GRApiManager::RateLimiter.new(
|
|
310
|
+
max_requests: rate_limit,
|
|
311
|
+
window_seconds: rate_limit_window
|
|
312
|
+
) : nil
|
|
313
|
+
|
|
16
314
|
@app_class = Class.new(Sinatra::Base) do
|
|
17
|
-
|
|
315
|
+
|
|
18
316
|
# Logs HTTP requests with status-based color coding.
|
|
19
|
-
def log_request(method, path,
|
|
317
|
+
def log_request(method, path, status_code)
|
|
20
318
|
color = status_code.between?(200, 299) ? "\e[32m" : "\e[31m"
|
|
21
319
|
puts "[#{Time.now.strftime('%H:%M:%S')}] #{color}#{method} #{path} - #{status_code}\e[0m"
|
|
22
320
|
end
|
|
23
321
|
|
|
24
322
|
# Casts string URL parameters to native Ruby types (Integer, Float, Boolean).
|
|
323
|
+
# Leaves values untouched if they are already non-String (e.g. FilePayload).
|
|
25
324
|
def smart_parse(hash)
|
|
26
325
|
hash.transform_values do |val|
|
|
326
|
+
next val unless val.is_a?(String)
|
|
27
327
|
case val
|
|
28
|
-
when 'true'
|
|
29
|
-
when 'false'
|
|
30
|
-
when
|
|
31
|
-
when
|
|
328
|
+
when 'true' then true
|
|
329
|
+
when 'false' then false
|
|
330
|
+
when /^\d+$/ then val.to_i
|
|
331
|
+
when /^\d+\.\d+$/ then val.to_f
|
|
32
332
|
else val
|
|
33
333
|
end
|
|
34
334
|
end
|
|
@@ -40,20 +340,49 @@ module GRApiManager
|
|
|
40
340
|
|
|
41
341
|
private
|
|
42
342
|
|
|
43
|
-
# Sets up Sinatra environment, CORS policies, and global error handlers.
|
|
343
|
+
# Sets up Sinatra environment, CORS policies, body size limit, rate limiting, and global error handlers.
|
|
44
344
|
def configure_app
|
|
45
|
-
app
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
app.set :
|
|
345
|
+
app = @app_class
|
|
346
|
+
max_body = @max_body_size
|
|
347
|
+
rate_limiter = @rate_limiter
|
|
348
|
+
|
|
349
|
+
app.set :port, @port
|
|
350
|
+
app.set :bind, '0.0.0.0'
|
|
351
|
+
app.set :token, @token
|
|
352
|
+
app.set :dev_mode, @dev_mode
|
|
353
|
+
app.set :show_exceptions, @dev_mode
|
|
50
354
|
app.set :host_authorization, { permitted_hosts: @permitted_hosts }
|
|
355
|
+
app.enable :static
|
|
51
356
|
|
|
52
|
-
# Enable broad CORS and handle preflight requests.
|
|
53
357
|
app.before do
|
|
54
|
-
headers 'Access-Control-Allow-Origin'
|
|
55
|
-
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
|
56
|
-
'Access-Control-Allow-Headers' => 'Content-Type, Authorization'
|
|
358
|
+
headers 'Access-Control-Allow-Origin' => '*',
|
|
359
|
+
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
|
360
|
+
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, Content-Disposition'
|
|
361
|
+
|
|
362
|
+
# Rate limiting — checked before anything else.
|
|
363
|
+
if rate_limiter
|
|
364
|
+
ip = request.ip
|
|
365
|
+
unless rate_limiter.allow?(ip)
|
|
366
|
+
remaining_reset = rate_limiter.window_seconds
|
|
367
|
+
headers 'Retry-After' => remaining_reset.to_s,
|
|
368
|
+
'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
|
|
369
|
+
'X-RateLimit-Remaining' => '0',
|
|
370
|
+
'X-RateLimit-Reset' => (Time.now.to_i + remaining_reset).to_s
|
|
371
|
+
halt 429, { error: "Too many requests", retry_after_seconds: remaining_reset }.to_json
|
|
372
|
+
end
|
|
373
|
+
# Add rate-limit headers on allowed requests too.
|
|
374
|
+
headers 'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
|
|
375
|
+
'X-RateLimit-Remaining' => rate_limiter.remaining(request.ip).to_s
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Body size limit (skip for read-only / headerless verbs).
|
|
379
|
+
unless %w[GET DELETE OPTIONS HEAD].include?(request.request_method)
|
|
380
|
+
content_length = request.content_length.to_i
|
|
381
|
+
if content_length > max_body
|
|
382
|
+
halt 413, { error: "Payload too large", max_bytes: max_body }.to_json
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
57
386
|
content_type :json
|
|
58
387
|
end
|
|
59
388
|
|
|
@@ -61,40 +390,59 @@ module GRApiManager
|
|
|
61
390
|
halt 200
|
|
62
391
|
end
|
|
63
392
|
|
|
64
|
-
# JSON formatted 404 response.
|
|
65
393
|
app.not_found do
|
|
66
394
|
status 404
|
|
67
395
|
{ error: "Endpoint not found", path: request.path_info }.to_json
|
|
68
396
|
end
|
|
69
397
|
|
|
70
|
-
# JSON formatted 500 response.
|
|
71
398
|
app.error do
|
|
72
399
|
e = env['sinatra.error']
|
|
73
400
|
status 500
|
|
74
|
-
|
|
401
|
+
if settings.dev_mode
|
|
402
|
+
{ error: "Internal Server Error", details: e.message,
|
|
403
|
+
class: e.class.to_s, backtrace: e.backtrace&.first(15) }.to_json
|
|
404
|
+
else
|
|
405
|
+
{ error: "Internal Server Error", details: e.message }.to_json
|
|
406
|
+
end
|
|
75
407
|
end
|
|
76
408
|
end
|
|
77
409
|
|
|
78
410
|
public
|
|
79
411
|
|
|
80
|
-
# Dynamically generate routing methods (get, post, put, delete).
|
|
81
|
-
%w[get post put delete].each do |verb|
|
|
412
|
+
# Dynamically generate routing methods (get, post, put, patch, delete).
|
|
413
|
+
%w[get post put patch delete].each do |verb|
|
|
82
414
|
define_method(verb) do |path, options = {}, &block|
|
|
83
415
|
register_route(verb, path, options, &block)
|
|
84
416
|
end
|
|
85
417
|
end
|
|
86
418
|
|
|
87
|
-
# Core routing logic: auth validation,
|
|
419
|
+
# Core routing logic: auth validation, body parsing, param merging, validation, execution.
|
|
420
|
+
#
|
|
421
|
+
# Supported body formats (auto-detected via Content-Type):
|
|
422
|
+
# application/json – standard JSON body
|
|
423
|
+
# multipart/form-data – form fields + file uploads
|
|
424
|
+
# application/octet-stream – raw binary stream
|
|
425
|
+
# image/*, video/*, audio/* – raw binary media
|
|
426
|
+
# application/pdf, etc. – raw binary document
|
|
427
|
+
# text/plain – plain text body
|
|
428
|
+
#
|
|
429
|
+
# Inside your block, params will contain:
|
|
430
|
+
# :_files => { field: FilePayload } – for multipart uploads
|
|
431
|
+
# :_raw_binary => FilePayload – for raw binary/media bodies
|
|
432
|
+
# :_raw_text => String – for text/plain bodies
|
|
433
|
+
#
|
|
434
|
+
# Route options:
|
|
435
|
+
# auth: Boolean – require Bearer Token (default: true)
|
|
436
|
+
# requires: Array – required parameter keys [:name, :email, ...]
|
|
88
437
|
def register_route(verb, path, options = {}, &block)
|
|
89
|
-
|
|
90
|
-
require_auth
|
|
438
|
+
verb_up = verb.to_s.upcase
|
|
439
|
+
require_auth = options.fetch(:auth, true)
|
|
91
440
|
required_params = options.fetch(:requires, [])
|
|
92
|
-
|
|
441
|
+
|
|
93
442
|
# Construct the full path with the optional prefix.
|
|
94
443
|
full_path = File.join('/', @prefix.to_s, path.to_s).gsub(%r{/+}, '/')
|
|
95
444
|
|
|
96
445
|
handler = proc do
|
|
97
|
-
|
|
98
446
|
# 1. Authentication check
|
|
99
447
|
if require_auth
|
|
100
448
|
auth_header = request.env["HTTP_AUTHORIZATION"]
|
|
@@ -102,46 +450,94 @@ module GRApiManager
|
|
|
102
450
|
halt 403, { error: "Invalid token" }.to_json if auth_header.split(" ").last != settings.token
|
|
103
451
|
end
|
|
104
452
|
|
|
105
|
-
# 2. Body parsing
|
|
453
|
+
# 2. Body parsing — smart detection based on Content-Type
|
|
106
454
|
parsed_body = {}
|
|
107
|
-
if [
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
rescue JSON::ParserError
|
|
113
|
-
halt 400, { error: "Invalid JSON body" }.to_json
|
|
114
|
-
end
|
|
455
|
+
if %w[POST PUT PATCH].include?(verb_up)
|
|
456
|
+
begin
|
|
457
|
+
parsed_body = GRApiManager::BodyParser.parse(request)
|
|
458
|
+
rescue ArgumentError => e
|
|
459
|
+
halt 400, { error: e.message }.to_json
|
|
115
460
|
end
|
|
116
461
|
end
|
|
117
462
|
|
|
118
|
-
# 3. Merge query parameters with parsed
|
|
119
|
-
|
|
463
|
+
# 3. Merge query/path parameters with parsed body.
|
|
464
|
+
# URL params go through smart_parse; body values are left as-is
|
|
465
|
+
# (so FilePayload objects, arrays, etc. are preserved).
|
|
466
|
+
url_params = smart_parse(params.reject { |_, v| v.is_a?(Hash) && v.key?(:tempfile) })
|
|
467
|
+
all_params = url_params.merge(parsed_body)
|
|
468
|
+
|
|
469
|
+
# 4. Declarative parameter validation (skips special _ keys and FilePayload values).
|
|
470
|
+
missing = required_params.select do |p|
|
|
471
|
+
val = all_params[p.to_sym]
|
|
472
|
+
val.nil? || (val.is_a?(String) && val.strip.empty?)
|
|
473
|
+
end
|
|
120
474
|
|
|
121
|
-
# 4. Declarative parameter validation
|
|
122
|
-
missing = required_params.select { |p| all_params[p.to_sym].nil? || all_params[p.to_sym].to_s.strip.empty? }
|
|
123
475
|
if missing.any?
|
|
124
476
|
status 400
|
|
125
|
-
log_request(
|
|
477
|
+
log_request(verb_up, full_path, 400)
|
|
126
478
|
next { error: "Missing required parameters", required: missing }.to_json
|
|
127
479
|
end
|
|
128
480
|
|
|
129
481
|
# 5. Execute user-defined block
|
|
130
482
|
result = instance_exec(all_params, &block)
|
|
131
|
-
log_request(
|
|
132
|
-
|
|
483
|
+
log_request(verb_up, full_path, response.status)
|
|
484
|
+
|
|
485
|
+
# If the block returned a String (e.g. already rendered binary data),
|
|
486
|
+
# pass it through unchanged. Otherwise serialize to JSON.
|
|
487
|
+
result.is_a?(String) ? result : result.to_json
|
|
133
488
|
end
|
|
134
489
|
|
|
135
490
|
@app_class.send(verb.downcase, full_path, &handler)
|
|
136
491
|
end
|
|
137
492
|
|
|
138
|
-
# Starts the Sinatra server
|
|
139
|
-
|
|
493
|
+
# Starts the Sinatra server.
|
|
494
|
+
#
|
|
495
|
+
# Options:
|
|
496
|
+
# workers: Integer – Puma worker processes (default: ENV['WEB_CONCURRENCY'] || 2)
|
|
497
|
+
# threads: String – min:max thread count per worker (default: '2:8')
|
|
498
|
+
#
|
|
499
|
+
# For sustained high traffic, run behind Nginx as a reverse proxy.
|
|
500
|
+
# See the README section "High Traffic & Concurrency" for production tuning.
|
|
501
|
+
def run!(workers: nil, threads: '2:8')
|
|
502
|
+
w = (workers || ENV.fetch('WEB_CONCURRENCY', 2)).to_i
|
|
503
|
+
min_t, max_t = threads.to_s.split(':').map(&:to_i)
|
|
504
|
+
max_t ||= min_t
|
|
505
|
+
mb = (@max_body_size.to_f / 1_048_576).round(1)
|
|
506
|
+
|
|
507
|
+
# Use Puma as the application server for concurrency.
|
|
508
|
+
@app_class.set :server, :puma
|
|
509
|
+
@app_class.set :server_settings, {
|
|
510
|
+
workers: w,
|
|
511
|
+
min_threads: min_t,
|
|
512
|
+
max_threads: max_t
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Background thread to purge stale rate-limit entries (prevents memory growth).
|
|
516
|
+
if @rate_limiter
|
|
517
|
+
rl = @rate_limiter
|
|
518
|
+
Thread.new do
|
|
519
|
+
loop do
|
|
520
|
+
sleep rl.window_seconds * 2
|
|
521
|
+
rl.cleanup!
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
rl_info = if @rate_limiter
|
|
527
|
+
"#{@rate_limiter.max_requests} req / #{@rate_limiter.window_seconds}s per IP"
|
|
528
|
+
else
|
|
529
|
+
'Disabled'
|
|
530
|
+
end
|
|
531
|
+
|
|
140
532
|
puts "============================================="
|
|
141
533
|
puts " GR API MANAGER STARTED"
|
|
142
|
-
puts " Port
|
|
143
|
-
puts " Auth
|
|
144
|
-
puts " Prefix
|
|
534
|
+
puts " Port : #{@port}"
|
|
535
|
+
puts " Auth : #{@token ? 'Enabled' : 'Public (no token)'}"
|
|
536
|
+
puts " Prefix : #{@prefix.empty? ? '/' : @prefix}"
|
|
537
|
+
puts " Max Body : #{mb} MB"
|
|
538
|
+
puts " Workers : #{w} | Threads: #{min_t}:#{max_t}"
|
|
539
|
+
puts " Rate Limit: #{rl_info}"
|
|
540
|
+
puts " Dev Mode : #{@dev_mode ? 'ON ⚠️ (disable in production)' : 'Off'}"
|
|
145
541
|
puts "============================================="
|
|
146
542
|
@app_class.run!
|
|
147
543
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gr_api_manager
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Razo
|
|
@@ -37,8 +37,24 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '2.8'
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: puma
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.0'
|
|
54
|
+
description: Eliminates boilerplate from REST API development. Handles auth, CORS,
|
|
55
|
+
param validation, type casting, multipart file uploads, raw binary/image/document
|
|
56
|
+
bodies, Base64, hexadecimal, rate limiting (429), Puma multi-worker concurrency,
|
|
57
|
+
and dev mode stack traces.
|
|
42
58
|
email:
|
|
43
59
|
- garabatoangelopolis@gmail.com
|
|
44
60
|
executables: []
|
|
@@ -71,5 +87,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
71
87
|
requirements: []
|
|
72
88
|
rubygems_version: 3.6.7
|
|
73
89
|
specification_version: 4
|
|
74
|
-
summary: A minimal
|
|
90
|
+
summary: A minimal Ruby wrapper around Sinatra with auth, file/binary support, rate
|
|
91
|
+
limiting, and Puma concurrency.
|
|
75
92
|
test_files: []
|