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/lib/gr_api_manager.rb
CHANGED
|
@@ -3,6 +3,8 @@ require 'json'
|
|
|
3
3
|
require 'dotenv/load'
|
|
4
4
|
require 'base64'
|
|
5
5
|
require 'tempfile'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'openssl'
|
|
6
8
|
|
|
7
9
|
module GRApiManager
|
|
8
10
|
# Convenience size helpers — use in max_body_size:
|
|
@@ -11,58 +13,283 @@ module GRApiManager
|
|
|
11
13
|
def self.mb(n) = n * 1_024 * 1_024
|
|
12
14
|
def self.gb(n) = n * 1_024 * 1_024 * 1_024
|
|
13
15
|
|
|
16
|
+
# Extracts the client IP from proxy headers (Cloudflare, X-Real-IP, X-Forwarded-For),
|
|
17
|
+
# falling back to request.ip or REMOTE_ADDR.
|
|
18
|
+
def self.extract_client_ip(request)
|
|
19
|
+
env = request.respond_to?(:env) ? request.env : request
|
|
20
|
+
return (request.respond_to?(:ip) ? request.ip : '127.0.0.1') unless env.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
if env['HTTP_CF_CONNECTING_IP'] && !env['HTTP_CF_CONNECTING_IP'].to_s.strip.empty?
|
|
23
|
+
return env['HTTP_CF_CONNECTING_IP'].to_s.strip
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if env['HTTP_X_REAL_IP'] && !env['HTTP_X_REAL_IP'].to_s.strip.empty?
|
|
27
|
+
return env['HTTP_X_REAL_IP'].to_s.strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if env['HTTP_X_FORWARDED_FOR'] && !env['HTTP_X_FORWARDED_FOR'].to_s.strip.empty?
|
|
31
|
+
client = env['HTTP_X_FORWARDED_FOR'].to_s.split(',').first
|
|
32
|
+
return client.strip if client && !client.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
request.respond_to?(:ip) ? request.ip : (env['REMOTE_ADDR'] || '127.0.0.1')
|
|
36
|
+
end
|
|
37
|
+
|
|
14
38
|
# ---------------------------------------------------------------------------
|
|
15
|
-
#
|
|
39
|
+
# JWT — Zero-dependency JSON Web Token encoder and decoder (HS256).
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
module JWT
|
|
42
|
+
class DecodeError < StandardError; end
|
|
43
|
+
class ExpiredSignature < DecodeError; end
|
|
44
|
+
|
|
45
|
+
def self.base64url_encode(str)
|
|
46
|
+
Base64.urlsafe_encode64(str, padding: false)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.base64url_decode(str)
|
|
50
|
+
padded = str + ('=' * ((4 - (str.length % 4)) % 4))
|
|
51
|
+
Base64.urlsafe_decode64(padded)
|
|
52
|
+
rescue ArgumentError => e
|
|
53
|
+
raise DecodeError, "Invalid Base64URL string: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Encodes a payload hash into a JWT token signed with HMAC-SHA256.
|
|
57
|
+
# Options:
|
|
58
|
+
# exp: Integer – expiration timestamp (epoch in seconds).
|
|
59
|
+
def self.encode(payload, secret, exp: nil, algorithm: 'HS256')
|
|
60
|
+
raise ArgumentError, "JWT secret cannot be blank" if secret.to_s.strip.empty?
|
|
61
|
+
|
|
62
|
+
data = payload.dup
|
|
63
|
+
data = data.transform_keys(&:to_sym) if data.is_a?(Hash)
|
|
64
|
+
data[:exp] = exp.to_i if exp
|
|
65
|
+
|
|
66
|
+
header = { typ: 'JWT', alg: algorithm }
|
|
67
|
+
header_b64 = base64url_encode(header.to_json)
|
|
68
|
+
payload_b64 = base64url_encode(data.to_json)
|
|
69
|
+
signing_input = "#{header_b64}.#{payload_b64}"
|
|
70
|
+
|
|
71
|
+
digest = OpenSSL::Digest.new('sha256')
|
|
72
|
+
signature = OpenSSL::HMAC.digest(digest, secret.to_s, signing_input)
|
|
73
|
+
signature_b64 = base64url_encode(signature)
|
|
74
|
+
|
|
75
|
+
"#{signing_input}.#{signature_b64}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Decodes and verifies a JWT token. Returns the payload hash with symbolized keys.
|
|
79
|
+
def self.decode(token, secret)
|
|
80
|
+
raise ArgumentError, "JWT secret cannot be blank" if secret.to_s.strip.empty?
|
|
81
|
+
raise DecodeError, "Token cannot be blank" if token.nil? || token.to_s.strip.empty?
|
|
82
|
+
|
|
83
|
+
parts = token.to_s.split('.')
|
|
84
|
+
raise DecodeError, "Invalid JWT format. Expected 3 segments separated by dots." unless parts.size == 3
|
|
85
|
+
|
|
86
|
+
header_b64, payload_b64, signature_b64 = parts
|
|
87
|
+
signing_input = "#{header_b64}.#{payload_b64}"
|
|
88
|
+
|
|
89
|
+
digest = OpenSSL::Digest.new('sha256')
|
|
90
|
+
expected_sig = OpenSSL::HMAC.digest(digest, secret.to_s, signing_input)
|
|
91
|
+
actual_sig = base64url_decode(signature_b64)
|
|
92
|
+
|
|
93
|
+
is_valid = if OpenSSL.respond_to?(:secure_compare)
|
|
94
|
+
OpenSSL.secure_compare(expected_sig, actual_sig)
|
|
95
|
+
else
|
|
96
|
+
expected_sig == actual_sig
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
raise DecodeError, "Invalid JWT signature" unless is_valid
|
|
100
|
+
|
|
101
|
+
payload_json = base64url_decode(payload_b64)
|
|
102
|
+
payload = JSON.parse(payload_json, symbolize_names: true)
|
|
103
|
+
|
|
104
|
+
if payload[:exp]
|
|
105
|
+
exp_time = payload[:exp].to_i
|
|
106
|
+
raise ExpiredSignature, "JWT signature has expired" if Time.now.to_i > exp_time
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
payload
|
|
110
|
+
rescue JSON::ParserError => e
|
|
111
|
+
raise DecodeError, "Invalid JSON payload in token: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Validator — declarative schema and type validation.
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
module Validator
|
|
119
|
+
EMAIL_REGEX = /\A[^\s@]+@[^\s@]+\.[^\s@]+\z/
|
|
120
|
+
URL_REGEX = /\Ahttps?:\/\/\S+\z/i
|
|
121
|
+
|
|
122
|
+
# Validates *params* against *schema* (Hash of field => expected_type).
|
|
123
|
+
# Returns [is_valid, errors_hash].
|
|
124
|
+
def self.validate(params, schema)
|
|
125
|
+
errors = {}
|
|
126
|
+
|
|
127
|
+
schema.each do |field, rule|
|
|
128
|
+
key = field.to_sym
|
|
129
|
+
val = params[key]
|
|
130
|
+
|
|
131
|
+
# Check presence
|
|
132
|
+
if val.nil? || (val.is_a?(String) && val.strip.empty?)
|
|
133
|
+
errors[key] = "is required"
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Validate type / contract rule
|
|
138
|
+
error_msg = validate_rule(val, rule)
|
|
139
|
+
errors[key] = error_msg if error_msg
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
[errors.empty?, errors]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private_class_method
|
|
146
|
+
|
|
147
|
+
def self.validate_rule(val, rule)
|
|
148
|
+
case rule
|
|
149
|
+
when :email
|
|
150
|
+
"must be a valid email address" unless val.to_s.match?(EMAIL_REGEX)
|
|
151
|
+
when :url
|
|
152
|
+
"must be a valid URL (http/https)" unless val.to_s.match?(URL_REGEX)
|
|
153
|
+
when :boolean
|
|
154
|
+
"must be a boolean (true or false)" unless val == true || val == false
|
|
155
|
+
when :file
|
|
156
|
+
"must be an uploaded file" unless val.is_a?(GRApiManager::FilePayload)
|
|
157
|
+
when Class
|
|
158
|
+
if rule == Integer
|
|
159
|
+
"must be an Integer" unless val.is_a?(Integer)
|
|
160
|
+
elsif rule == Float
|
|
161
|
+
"must be a Float" unless val.is_a?(Float)
|
|
162
|
+
elsif rule == Numeric
|
|
163
|
+
"must be a Numeric" unless val.is_a?(Numeric)
|
|
164
|
+
elsif rule == String
|
|
165
|
+
"must be a String" unless val.is_a?(String)
|
|
166
|
+
elsif rule == Hash
|
|
167
|
+
"must be an Object/Hash" unless val.is_a?(Hash)
|
|
168
|
+
elsif rule == Array
|
|
169
|
+
"must be an Array" unless val.is_a?(Array)
|
|
170
|
+
else
|
|
171
|
+
"must be a #{rule}" unless val.is_a?(rule)
|
|
172
|
+
end
|
|
173
|
+
when Array
|
|
174
|
+
"must be one of: #{rule.map(&:to_s).join(', ')}" unless rule.map(&:to_s).include?(val.to_s)
|
|
175
|
+
when Regexp
|
|
176
|
+
"does not match expected format" unless val.to_s.match?(rule)
|
|
177
|
+
when Proc
|
|
178
|
+
"is invalid" unless rule.call(val)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# RateLimiter — thread-safe sliding-window rate limiter per IP/key.
|
|
16
185
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
186
|
+
# Supports pluggable storage backends (default: MemoryStore with Mutex).
|
|
187
|
+
# Prevents client flooding and supports distributed stores like Redis.
|
|
19
188
|
# ---------------------------------------------------------------------------
|
|
20
189
|
class RateLimiter
|
|
21
|
-
attr_reader :max_requests, :window_seconds
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
190
|
+
attr_reader :max_requests, :window_seconds, :store
|
|
191
|
+
|
|
192
|
+
# In-memory sliding-window store using Mutex.
|
|
193
|
+
class MemoryStore
|
|
194
|
+
def initialize
|
|
195
|
+
@store = Hash.new { |h, k| h[k] = [] }
|
|
196
|
+
@mutex = Mutex.new
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def allow?(key, max, window)
|
|
200
|
+
@mutex.synchronize do
|
|
201
|
+
now = Time.now.to_f
|
|
202
|
+
cutoff = now - window
|
|
203
|
+
@store[key].reject! { |t| t < cutoff }
|
|
204
|
+
return false if @store[key].size >= max
|
|
205
|
+
@store[key] << now
|
|
206
|
+
true
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def remaining(key, max, window)
|
|
211
|
+
@mutex.synchronize do
|
|
212
|
+
cutoff = Time.now.to_f - window
|
|
213
|
+
active = @store[key].count { |t| t >= cutoff }
|
|
214
|
+
[max - active, 0].max
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def cleanup!(window)
|
|
219
|
+
@mutex.synchronize do
|
|
220
|
+
cutoff = Time.now.to_f - window
|
|
221
|
+
@store.each_value { |times| times.reject! { |t| t < cutoff } }
|
|
222
|
+
@store.delete_if { |_, times| times.empty? }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def reset!(key = nil)
|
|
227
|
+
@mutex.synchronize do
|
|
228
|
+
if key
|
|
229
|
+
@store.delete(key)
|
|
230
|
+
else
|
|
231
|
+
@store.clear
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def size
|
|
237
|
+
@mutex.synchronize { @store.size }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def initialize(max_requests:, window_seconds:, store: nil)
|
|
242
|
+
@max_requests = max_requests
|
|
243
|
+
@window_seconds = window_seconds
|
|
244
|
+
@store = store || MemoryStore.new
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Returns true if the request from *key* is within the allowed rate.
|
|
248
|
+
def allow?(key)
|
|
249
|
+
if @store.respond_to?(:allow?)
|
|
250
|
+
if @store.method(:allow?).arity == 1
|
|
251
|
+
@store.allow?(key)
|
|
252
|
+
else
|
|
253
|
+
@store.allow?(key, @max_requests, @window_seconds)
|
|
254
|
+
end
|
|
255
|
+
else
|
|
39
256
|
true
|
|
40
257
|
end
|
|
41
258
|
end
|
|
42
259
|
|
|
43
|
-
# Remaining requests allowed for *
|
|
44
|
-
def remaining(
|
|
45
|
-
@
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
260
|
+
# Remaining requests allowed for *key* in the current window.
|
|
261
|
+
def remaining(key)
|
|
262
|
+
if @store.respond_to?(:remaining)
|
|
263
|
+
if @store.method(:remaining).arity == 1
|
|
264
|
+
@store.remaining(key)
|
|
265
|
+
else
|
|
266
|
+
@store.remaining(key, @max_requests, @window_seconds)
|
|
267
|
+
end
|
|
268
|
+
else
|
|
269
|
+
@max_requests
|
|
49
270
|
end
|
|
50
271
|
end
|
|
51
272
|
|
|
52
273
|
# Removes stale entries — call periodically to prevent memory growth.
|
|
53
274
|
def cleanup!
|
|
54
|
-
@
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
275
|
+
if @store.respond_to?(:cleanup!)
|
|
276
|
+
if @store.method(:cleanup!).arity == 0
|
|
277
|
+
@store.cleanup!
|
|
278
|
+
else
|
|
279
|
+
@store.cleanup!(@window_seconds)
|
|
280
|
+
end
|
|
58
281
|
end
|
|
59
282
|
end
|
|
60
283
|
|
|
284
|
+
# Resets counters for a specific key or all keys.
|
|
285
|
+
def reset!(key = nil)
|
|
286
|
+
@store.reset!(key) if @store.respond_to?(:reset!)
|
|
287
|
+
end
|
|
288
|
+
|
|
61
289
|
# Summary hash — safe to log or expose on a diagnostic endpoint.
|
|
62
290
|
def stats
|
|
63
|
-
@
|
|
64
|
-
|
|
65
|
-
end
|
|
291
|
+
tracked = @store.respond_to?(:size) ? @store.size : :external
|
|
292
|
+
{ tracked_ips: tracked, max_requests: @max_requests, window_seconds: @window_seconds }
|
|
66
293
|
end
|
|
67
294
|
end
|
|
68
295
|
|
|
@@ -76,16 +303,16 @@ module GRApiManager
|
|
|
76
303
|
@tempfile = tempfile
|
|
77
304
|
@filename = filename.to_s
|
|
78
305
|
@content_type = content_type.to_s
|
|
79
|
-
@size = tempfile.respond_to?(:size) ? tempfile.size : tempfile.length
|
|
306
|
+
@size = tempfile.respond_to?(:size) ? tempfile.size : (tempfile.respond_to?(:length) ? tempfile.length : tempfile.to_s.bytesize)
|
|
80
307
|
end
|
|
81
308
|
|
|
82
309
|
# Returns the raw binary content of the file as a String (encoding: BINARY).
|
|
83
310
|
def read
|
|
84
311
|
if @tempfile.respond_to?(:read)
|
|
85
|
-
@tempfile.rewind
|
|
312
|
+
@tempfile.rewind if @tempfile.respond_to?(:rewind)
|
|
86
313
|
@tempfile.read
|
|
87
314
|
else
|
|
88
|
-
@tempfile.to_s.force_encoding(Encoding::BINARY)
|
|
315
|
+
@tempfile.to_s.dup.force_encoding(Encoding::BINARY)
|
|
89
316
|
end
|
|
90
317
|
end
|
|
91
318
|
|
|
@@ -101,6 +328,8 @@ module GRApiManager
|
|
|
101
328
|
|
|
102
329
|
# Saves the uploaded content to *dest_path* on disk. Returns dest_path.
|
|
103
330
|
def save_to(dest_path)
|
|
331
|
+
dir = File.dirname(dest_path)
|
|
332
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
104
333
|
File.open(dest_path, 'wb') { |f| f.write(read) }
|
|
105
334
|
dest_path
|
|
106
335
|
end
|
|
@@ -128,14 +357,6 @@ module GRApiManager
|
|
|
128
357
|
|
|
129
358
|
# ---------------------------------------------------------------------------
|
|
130
359
|
# 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
360
|
# ---------------------------------------------------------------------------
|
|
140
361
|
module BodyParser
|
|
141
362
|
|
|
@@ -146,12 +367,8 @@ module GRApiManager
|
|
|
146
367
|
].freeze
|
|
147
368
|
|
|
148
369
|
# 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
370
|
def self.parse(request)
|
|
154
|
-
content_type = (request.content_type || '').split(';').first.strip.downcase
|
|
371
|
+
content_type = (request.content_type || '').split(';').first.to_s.strip.downcase
|
|
155
372
|
|
|
156
373
|
case content_type
|
|
157
374
|
when 'application/json'
|
|
@@ -161,7 +378,6 @@ module GRApiManager
|
|
|
161
378
|
parse_multipart(request)
|
|
162
379
|
|
|
163
380
|
when 'application/x-www-form-urlencoded'
|
|
164
|
-
# Sinatra already exposes these in `params` — nothing extra to do.
|
|
165
381
|
{}
|
|
166
382
|
|
|
167
383
|
when 'text/plain'
|
|
@@ -169,17 +385,14 @@ module GRApiManager
|
|
|
169
385
|
{ _raw_text: text }
|
|
170
386
|
|
|
171
387
|
else
|
|
172
|
-
# Treat anything else that looks binary as a raw binary upload.
|
|
173
388
|
if binary_content_type?(content_type)
|
|
174
389
|
parse_raw_binary(request, content_type)
|
|
175
390
|
else
|
|
176
|
-
# Last resort: try JSON, silently fall back to empty hash.
|
|
177
391
|
parse_json(request) rescue {}
|
|
178
392
|
end
|
|
179
393
|
end
|
|
180
394
|
end
|
|
181
395
|
|
|
182
|
-
# -------------------------------------------------------------------------
|
|
183
396
|
private_class_method
|
|
184
397
|
|
|
185
398
|
def self.parse_json(request)
|
|
@@ -198,14 +411,12 @@ module GRApiManager
|
|
|
198
411
|
sym = key.to_sym
|
|
199
412
|
|
|
200
413
|
if value.is_a?(Hash) && value.key?(:tempfile)
|
|
201
|
-
# Rack multipart file upload hash
|
|
202
414
|
files[sym] = FilePayload.new(
|
|
203
415
|
tempfile: value[:tempfile],
|
|
204
416
|
filename: value[:filename] || key,
|
|
205
417
|
content_type: value[:type] || 'application/octet-stream'
|
|
206
418
|
)
|
|
207
419
|
elsif value.is_a?(Array)
|
|
208
|
-
# Multiple file inputs with the same name
|
|
209
420
|
files[sym] = value.map do |v|
|
|
210
421
|
if v.is_a?(Hash) && v.key?(:tempfile)
|
|
211
422
|
FilePayload.new(
|
|
@@ -230,13 +441,10 @@ module GRApiManager
|
|
|
230
441
|
raw = request.body.read
|
|
231
442
|
return {} if raw.nil? || raw.empty?
|
|
232
443
|
|
|
233
|
-
# Try to derive a filename from the Content-Disposition header, if any.
|
|
234
444
|
disposition = request.env['HTTP_CONTENT_DISPOSITION'] || ''
|
|
235
445
|
filename = disposition[/filename="?([^";]+)"?/, 1] || "upload#{ext_for(content_type)}"
|
|
236
446
|
|
|
237
|
-
# Wrap the raw bytes in a StringIO so FilePayload can rewind/read it.
|
|
238
447
|
io = StringIO.new(raw.force_encoding(Encoding::BINARY))
|
|
239
|
-
io.define_singleton_method(:size) { raw.bytesize }
|
|
240
448
|
|
|
241
449
|
payload = FilePayload.new(
|
|
242
450
|
tempfile: io,
|
|
@@ -251,7 +459,6 @@ module GRApiManager
|
|
|
251
459
|
BINARY_MIME_PREFIXES.any? { |prefix| ct.start_with?(prefix) }
|
|
252
460
|
end
|
|
253
461
|
|
|
254
|
-
# Maps common MIME types to file extensions for unnamed raw uploads.
|
|
255
462
|
def self.ext_for(content_type)
|
|
256
463
|
{
|
|
257
464
|
'image/jpeg' => '.jpg',
|
|
@@ -273,43 +480,105 @@ module GRApiManager
|
|
|
273
480
|
end
|
|
274
481
|
end
|
|
275
482
|
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
# RouteGroup — provides nested route grouping with shared prefixes and options.
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
class RouteGroup
|
|
487
|
+
attr_reader :server, :prefix, :options
|
|
488
|
+
|
|
489
|
+
def initialize(server, prefix = '', options = {})
|
|
490
|
+
@server = server
|
|
491
|
+
@prefix = prefix.to_s
|
|
492
|
+
@options = options
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Dynamically generate routing methods inside the group (get, post, put, patch, delete).
|
|
496
|
+
%w[get post put patch delete].each do |verb|
|
|
497
|
+
define_method(verb) do |path, route_options = {}, &block|
|
|
498
|
+
combined_path = File.join('/', @prefix, path.to_s).gsub(%r{/+}, '/')
|
|
499
|
+
merged_options = @options.merge(route_options)
|
|
500
|
+
|
|
501
|
+
if @options[:requires] && route_options[:requires]
|
|
502
|
+
merged_options[:requires] = merge_requires(@options[:requires], route_options[:requires])
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
@server.register_route(verb, combined_path, merged_options, &block)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Nested sub-grouping.
|
|
510
|
+
def group(sub_prefix = '', sub_options = {}, &block)
|
|
511
|
+
combined_prefix = File.join('/', @prefix, sub_prefix.to_s).gsub(%r{/+}, '/')
|
|
512
|
+
merged_options = @options.merge(sub_options)
|
|
513
|
+
|
|
514
|
+
if @options[:requires] && sub_options[:requires]
|
|
515
|
+
merged_options[:requires] = merge_requires(@options[:requires], sub_options[:requires])
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
sub_group = RouteGroup.new(@server, combined_prefix, merged_options)
|
|
519
|
+
block.call(sub_group) if block
|
|
520
|
+
sub_group
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
private
|
|
524
|
+
|
|
525
|
+
def merge_requires(req1, req2)
|
|
526
|
+
if req1.is_a?(Hash) && req2.is_a?(Hash)
|
|
527
|
+
req1.merge(req2)
|
|
528
|
+
elsif req1.is_a?(Array) && req2.is_a?(Array)
|
|
529
|
+
(req1 + req2).uniq
|
|
530
|
+
else
|
|
531
|
+
req2
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
276
536
|
# ---------------------------------------------------------------------------
|
|
277
537
|
# Server — the public-facing DSL.
|
|
278
538
|
# ---------------------------------------------------------------------------
|
|
279
539
|
class Server
|
|
280
|
-
attr_reader :app_class
|
|
540
|
+
attr_reader :app_class, :rate_limiter, :jwt_secret
|
|
281
541
|
|
|
282
542
|
# Initializes the server configuration.
|
|
283
543
|
#
|
|
284
544
|
# Options:
|
|
285
|
-
# port:
|
|
286
|
-
# bearer_token:
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
545
|
+
# port: Integer – listening port (default: ENV['PORT'] || 4000)
|
|
546
|
+
# bearer_token: String – Bearer token for auth (default: ENV['API_TOKEN'])
|
|
547
|
+
# jwt_secret: String – Secret key for signing/decoding JWTs (default: ENV['JWT_SECRET'])
|
|
548
|
+
# permitted_hosts: Array – host allowlist; empty = allow all
|
|
549
|
+
# prefix: String – route prefix, e.g. '/api/v1'
|
|
550
|
+
# max_body_size: Integer – maximum accepted body in bytes (default: 50 MB)
|
|
551
|
+
# dev_mode: Boolean – show full stack traces on 500 (default: false)
|
|
552
|
+
# rate_limit: Integer – max requests per IP per window (nil = disabled)
|
|
553
|
+
# rate_limit_window: Integer – sliding window in seconds (default: 60)
|
|
554
|
+
# rate_limit_store: Object – custom store object (default: MemoryStore)
|
|
555
|
+
# trust_proxy_headers: Boolean – inspect Cloudflare/X-Real-IP/X-Forwarded-For headers (default: true)
|
|
293
556
|
def initialize(
|
|
294
557
|
port: nil,
|
|
295
558
|
bearer_token: nil,
|
|
559
|
+
jwt_secret: nil,
|
|
296
560
|
permitted_hosts: [],
|
|
297
561
|
prefix: '',
|
|
298
|
-
max_body_size:
|
|
299
|
-
dev_mode:
|
|
300
|
-
rate_limit:
|
|
301
|
-
rate_limit_window:
|
|
562
|
+
max_body_size: GRApiManager.mb(50),
|
|
563
|
+
dev_mode: false,
|
|
564
|
+
rate_limit: nil,
|
|
565
|
+
rate_limit_window: 60,
|
|
566
|
+
rate_limit_store: nil,
|
|
567
|
+
trust_proxy_headers: true
|
|
302
568
|
)
|
|
303
|
-
@port
|
|
304
|
-
@token
|
|
305
|
-
@
|
|
306
|
-
@
|
|
307
|
-
@
|
|
308
|
-
@
|
|
309
|
-
@
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
569
|
+
@port = port || ENV['PORT'] || 4000
|
|
570
|
+
@token = bearer_token || ENV['API_TOKEN']
|
|
571
|
+
@jwt_secret = jwt_secret || ENV['JWT_SECRET']
|
|
572
|
+
@permitted_hosts = permitted_hosts.empty? ? [] : permitted_hosts
|
|
573
|
+
@prefix = prefix
|
|
574
|
+
@max_body_size = max_body_size
|
|
575
|
+
@dev_mode = dev_mode
|
|
576
|
+
@trust_proxy_headers = trust_proxy_headers
|
|
577
|
+
@rate_limiter = rate_limit ? GRApiManager::RateLimiter.new(
|
|
578
|
+
max_requests: rate_limit,
|
|
579
|
+
window_seconds: rate_limit_window,
|
|
580
|
+
store: rate_limit_store
|
|
581
|
+
) : nil
|
|
313
582
|
|
|
314
583
|
@app_class = Class.new(Sinatra::Base) do
|
|
315
584
|
|
|
@@ -320,15 +589,14 @@ module GRApiManager
|
|
|
320
589
|
end
|
|
321
590
|
|
|
322
591
|
# Casts string URL parameters to native Ruby types (Integer, Float, Boolean).
|
|
323
|
-
# Leaves values untouched if they are already non-String (e.g. FilePayload).
|
|
324
592
|
def smart_parse(hash)
|
|
325
593
|
hash.transform_values do |val|
|
|
326
594
|
next val unless val.is_a?(String)
|
|
327
595
|
case val
|
|
328
596
|
when 'true' then true
|
|
329
597
|
when 'false' then false
|
|
330
|
-
when
|
|
331
|
-
when
|
|
598
|
+
when /^-?\d+$/ then val.to_i
|
|
599
|
+
when /^-?\d+\.\d+$/ then val.to_f
|
|
332
600
|
else val
|
|
333
601
|
end
|
|
334
602
|
end
|
|
@@ -338,19 +606,42 @@ module GRApiManager
|
|
|
338
606
|
configure_app
|
|
339
607
|
end
|
|
340
608
|
|
|
609
|
+
# Encodes a payload into a JWT token using the configured jwt_secret.
|
|
610
|
+
def jwt_encode(payload, exp: nil)
|
|
611
|
+
raise "No jwt_secret configured for this server" unless @jwt_secret
|
|
612
|
+
GRApiManager::JWT.encode(payload, @jwt_secret, exp: exp)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Decodes a JWT token using the configured jwt_secret.
|
|
616
|
+
def jwt_decode(token)
|
|
617
|
+
raise "No jwt_secret configured for this server" unless @jwt_secret
|
|
618
|
+
GRApiManager::JWT.decode(token, @jwt_secret)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Groups routes under a common prefix with inherited options.
|
|
622
|
+
def group(prefix = '', options = {}, &block)
|
|
623
|
+
route_group = RouteGroup.new(self, prefix, options)
|
|
624
|
+
block.call(route_group) if block
|
|
625
|
+
route_group
|
|
626
|
+
end
|
|
627
|
+
|
|
341
628
|
private
|
|
342
629
|
|
|
343
630
|
# Sets up Sinatra environment, CORS policies, body size limit, rate limiting, and global error handlers.
|
|
344
631
|
def configure_app
|
|
345
|
-
app
|
|
346
|
-
max_body
|
|
347
|
-
rate_limiter
|
|
632
|
+
app = @app_class
|
|
633
|
+
max_body = @max_body_size
|
|
634
|
+
rate_limiter = @rate_limiter
|
|
635
|
+
trust_proxy_headers = @trust_proxy_headers
|
|
348
636
|
|
|
349
637
|
app.set :port, @port
|
|
350
638
|
app.set :bind, '0.0.0.0'
|
|
351
639
|
app.set :token, @token
|
|
640
|
+
app.set :jwt_secret, @jwt_secret
|
|
352
641
|
app.set :dev_mode, @dev_mode
|
|
353
|
-
app.set :show_exceptions,
|
|
642
|
+
app.set :show_exceptions, false
|
|
643
|
+
app.set :raise_errors, false
|
|
644
|
+
app.set :dump_errors, false
|
|
354
645
|
app.set :host_authorization, { permitted_hosts: @permitted_hosts }
|
|
355
646
|
app.enable :static
|
|
356
647
|
|
|
@@ -361,8 +652,8 @@ module GRApiManager
|
|
|
361
652
|
|
|
362
653
|
# Rate limiting — checked before anything else.
|
|
363
654
|
if rate_limiter
|
|
364
|
-
|
|
365
|
-
unless rate_limiter.allow?(
|
|
655
|
+
client_ip = trust_proxy_headers ? GRApiManager.extract_client_ip(request) : request.ip
|
|
656
|
+
unless rate_limiter.allow?(client_ip)
|
|
366
657
|
remaining_reset = rate_limiter.window_seconds
|
|
367
658
|
headers 'Retry-After' => remaining_reset.to_s,
|
|
368
659
|
'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
|
|
@@ -372,7 +663,7 @@ module GRApiManager
|
|
|
372
663
|
end
|
|
373
664
|
# Add rate-limit headers on allowed requests too.
|
|
374
665
|
headers 'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
|
|
375
|
-
'X-RateLimit-Remaining' => rate_limiter.remaining(
|
|
666
|
+
'X-RateLimit-Remaining' => rate_limiter.remaining(client_ip).to_s
|
|
376
667
|
end
|
|
377
668
|
|
|
378
669
|
# Body size limit (skip for read-only / headerless verbs).
|
|
@@ -392,17 +683,19 @@ module GRApiManager
|
|
|
392
683
|
|
|
393
684
|
app.not_found do
|
|
394
685
|
status 404
|
|
686
|
+
content_type :json
|
|
395
687
|
{ error: "Endpoint not found", path: request.path_info }.to_json
|
|
396
688
|
end
|
|
397
689
|
|
|
398
690
|
app.error do
|
|
399
691
|
e = env['sinatra.error']
|
|
400
692
|
status 500
|
|
693
|
+
content_type :json
|
|
401
694
|
if settings.dev_mode
|
|
402
|
-
{ error: "Internal Server Error", details: e
|
|
403
|
-
class: e
|
|
695
|
+
{ error: "Internal Server Error", details: e&.message,
|
|
696
|
+
class: e&.class&.to_s, backtrace: e&.backtrace&.first(15) }.to_json
|
|
404
697
|
else
|
|
405
|
-
{ error: "Internal Server Error", details: e
|
|
698
|
+
{ error: "Internal Server Error", details: e&.message }.to_json
|
|
406
699
|
end
|
|
407
700
|
end
|
|
408
701
|
end
|
|
@@ -417,37 +710,46 @@ module GRApiManager
|
|
|
417
710
|
end
|
|
418
711
|
|
|
419
712
|
# 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, ...]
|
|
437
713
|
def register_route(verb, path, options = {}, &block)
|
|
438
|
-
verb_up
|
|
439
|
-
require_auth
|
|
440
|
-
required_params = options.fetch(:requires,
|
|
714
|
+
verb_up = verb.to_s.upcase
|
|
715
|
+
require_auth = options.fetch(:auth, true)
|
|
716
|
+
required_params = options.fetch(:requires, nil)
|
|
441
717
|
|
|
442
718
|
# Construct the full path with the optional prefix.
|
|
443
719
|
full_path = File.join('/', @prefix.to_s, path.to_s).gsub(%r{/+}, '/')
|
|
444
720
|
|
|
445
721
|
handler = proc do
|
|
446
722
|
# 1. Authentication check
|
|
723
|
+
jwt_user = nil
|
|
447
724
|
if require_auth
|
|
448
725
|
auth_header = request.env["HTTP_AUTHORIZATION"]
|
|
449
726
|
halt 401, { error: "Token required. Format: 'Bearer <token>'" }.to_json if auth_header.nil?
|
|
450
|
-
|
|
727
|
+
|
|
728
|
+
raw_token = auth_header.split(" ").last
|
|
729
|
+
|
|
730
|
+
if require_auth == :jwt || (require_auth == true && settings.jwt_secret && settings.token.nil?)
|
|
731
|
+
# JWT authentication mode
|
|
732
|
+
halt 500, { error: "Server error: jwt_secret is not configured" }.to_json unless settings.jwt_secret
|
|
733
|
+
begin
|
|
734
|
+
jwt_user = GRApiManager::JWT.decode(raw_token, settings.jwt_secret)
|
|
735
|
+
rescue GRApiManager::JWT::DecodeError => e
|
|
736
|
+
halt 401, { error: "Invalid token: #{e.message}" }.to_json
|
|
737
|
+
end
|
|
738
|
+
else
|
|
739
|
+
# Static Bearer Token mode
|
|
740
|
+
if raw_token != settings.token
|
|
741
|
+
# Fallback: if jwt_secret is set, try JWT decoding
|
|
742
|
+
if settings.jwt_secret
|
|
743
|
+
begin
|
|
744
|
+
jwt_user = GRApiManager::JWT.decode(raw_token, settings.jwt_secret)
|
|
745
|
+
rescue GRApiManager::JWT::DecodeError
|
|
746
|
+
halt 403, { error: "Invalid token" }.to_json
|
|
747
|
+
end
|
|
748
|
+
else
|
|
749
|
+
halt 403, { error: "Invalid token" }.to_json
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
end
|
|
451
753
|
end
|
|
452
754
|
|
|
453
755
|
# 2. Body parsing — smart detection based on Content-Type
|
|
@@ -461,29 +763,40 @@ module GRApiManager
|
|
|
461
763
|
end
|
|
462
764
|
|
|
463
765
|
# 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
766
|
url_params = smart_parse(params.reject { |_, v| v.is_a?(Hash) && v.key?(:tempfile) })
|
|
467
767
|
all_params = url_params.merge(parsed_body)
|
|
468
768
|
|
|
469
|
-
#
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
769
|
+
# Inyect JWT payload if authenticated via JWT
|
|
770
|
+
if jwt_user
|
|
771
|
+
all_params[:current_user] = jwt_user
|
|
772
|
+
all_params[:jwt_payload] = jwt_user
|
|
473
773
|
end
|
|
474
774
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
775
|
+
# 4. Declarative parameter validation (Array of keys or Hash schema)
|
|
776
|
+
if required_params.is_a?(Hash)
|
|
777
|
+
is_valid, errors = GRApiManager::Validator.validate(all_params, required_params)
|
|
778
|
+
unless is_valid
|
|
779
|
+
status 400
|
|
780
|
+
log_request(verb_up, full_path, 400)
|
|
781
|
+
next { error: "Validation failed", errors: errors }.to_json
|
|
782
|
+
end
|
|
783
|
+
elsif required_params.is_a?(Array) && required_params.any?
|
|
784
|
+
missing = required_params.select do |p|
|
|
785
|
+
val = all_params[p.to_sym]
|
|
786
|
+
val.nil? || (val.is_a?(String) && val.strip.empty?)
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
if missing.any?
|
|
790
|
+
status 400
|
|
791
|
+
log_request(verb_up, full_path, 400)
|
|
792
|
+
next { error: "Missing required parameters", required: missing }.to_json
|
|
793
|
+
end
|
|
479
794
|
end
|
|
480
795
|
|
|
481
796
|
# 5. Execute user-defined block
|
|
482
797
|
result = instance_exec(all_params, &block)
|
|
483
798
|
log_request(verb_up, full_path, response.status)
|
|
484
799
|
|
|
485
|
-
# If the block returned a String (e.g. already rendered binary data),
|
|
486
|
-
# pass it through unchanged. Otherwise serialize to JSON.
|
|
487
800
|
result.is_a?(String) ? result : result.to_json
|
|
488
801
|
end
|
|
489
802
|
|
|
@@ -491,13 +804,6 @@ module GRApiManager
|
|
|
491
804
|
end
|
|
492
805
|
|
|
493
806
|
# 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
807
|
def run!(workers: nil, threads: '2:8')
|
|
502
808
|
w = (workers || ENV.fetch('WEB_CONCURRENCY', 2)).to_i
|
|
503
809
|
min_t, max_t = threads.to_s.split(':').map(&:to_i)
|
|
@@ -512,7 +818,7 @@ module GRApiManager
|
|
|
512
818
|
max_threads: max_t
|
|
513
819
|
}
|
|
514
820
|
|
|
515
|
-
# Background thread to purge stale rate-limit entries
|
|
821
|
+
# Background thread to purge stale rate-limit entries.
|
|
516
822
|
if @rate_limiter
|
|
517
823
|
rl = @rate_limiter
|
|
518
824
|
Thread.new do
|
|
@@ -529,10 +835,15 @@ module GRApiManager
|
|
|
529
835
|
'Disabled'
|
|
530
836
|
end
|
|
531
837
|
|
|
838
|
+
auth_info = []
|
|
839
|
+
auth_info << "Bearer Token" if @token
|
|
840
|
+
auth_info << "JWT (HS256)" if @jwt_secret
|
|
841
|
+
auth_display = auth_info.empty? ? "Public (no token)" : auth_info.join(' + ')
|
|
842
|
+
|
|
532
843
|
puts "============================================="
|
|
533
844
|
puts " GR API MANAGER STARTED"
|
|
534
845
|
puts " Port : #{@port}"
|
|
535
|
-
puts " Auth : #{
|
|
846
|
+
puts " Auth : #{auth_display}"
|
|
536
847
|
puts " Prefix : #{@prefix.empty? ? '/' : @prefix}"
|
|
537
848
|
puts " Max Body : #{mb} MB"
|
|
538
849
|
puts " Workers : #{w} | Threads: #{min_t}:#{max_t}"
|