gr_api_manager 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +424 -637
  3. data/README_ES.md +615 -0
  4. data/lib/gr_api_manager.rb +449 -138
  5. metadata +60 -13
@@ -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
- # RateLimiterthread-safe sliding-window rate limiter per IP address.
39
+ # JWTZero-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
- # Prevents any single client from flooding the server. Uses a mutex-protected
18
- # in-memory store with automatic cleanup to avoid unbounded memory growth.
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
- 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
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 *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
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
- @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? }
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
- @mutex.synchronize do
64
- { tracked_ips: @store.size, max_requests: @max, window_seconds: @window }
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: 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: Booleanshow full stack traces on 500 (default: false)
291
- # rate_limit: Integermax requests per IP per window (nil = disabled)
292
- # rate_limit_window: Integer – sliding window in seconds (default: 60)
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: Integermaximum accepted body in bytes (default: 50 MB)
551
+ # dev_mode: Booleanshow 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: GRApiManager.mb(50),
299
- dev_mode: false,
300
- rate_limit: nil,
301
- rate_limit_window: 60
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 = port || ENV['PORT'] || 4000
304
- @token = bearer_token || ENV['API_TOKEN']
305
- @permitted_hosts = permitted_hosts.empty? ? [] : permitted_hosts
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
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 /^\d+$/ then val.to_i
331
- when /^\d+\.\d+$/ then val.to_f
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 = @app_class
346
- max_body = @max_body_size
347
- rate_limiter = @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, @dev_mode
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
- ip = request.ip
365
- unless rate_limiter.allow?(ip)
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(request.ip).to_s
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.message,
403
- class: e.class.to_s, backtrace: e.backtrace&.first(15) }.to_json
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.message }.to_json
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 = verb.to_s.upcase
439
- require_auth = options.fetch(:auth, true)
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
- halt 403, { error: "Invalid token" }.to_json if auth_header.split(" ").last != settings.token
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
- # 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?)
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
- if missing.any?
476
- status 400
477
- log_request(verb_up, full_path, 400)
478
- next { error: "Missing required parameters", required: missing }.to_json
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 (prevents memory growth).
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 : #{@token ? 'Enabled' : 'Public (no token)'}"
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}"