gr_api_manager 0.1.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 +456 -187
  3. data/README_ES.md +615 -0
  4. data/lib/gr_api_manager.rb +766 -59
  5. metadata +72 -8
@@ -1,22 +1,589 @@
1
1
  require 'sinatra/base'
2
2
  require 'json'
3
3
  require 'dotenv/load'
4
+ require 'base64'
5
+ require 'tempfile'
6
+ require 'fileutils'
7
+ require 'openssl'
4
8
 
5
9
  module GRApiManager
10
+ # Convenience size helpers — use in max_body_size:
11
+ # GRApiManager.mb(50) => 50 MB in bytes
12
+ # GRApiManager.gb(2) => 2 GB in bytes
13
+ def self.mb(n) = n * 1_024 * 1_024
14
+ def self.gb(n) = n * 1_024 * 1_024 * 1_024
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
+
38
+ # ---------------------------------------------------------------------------
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.
185
+ #
186
+ # Supports pluggable storage backends (default: MemoryStore with Mutex).
187
+ # Prevents client flooding and supports distributed stores like Redis.
188
+ # ---------------------------------------------------------------------------
189
+ class RateLimiter
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
256
+ true
257
+ end
258
+ end
259
+
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
270
+ end
271
+ end
272
+
273
+ # Removes stale entries — call periodically to prevent memory growth.
274
+ def cleanup!
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
281
+ end
282
+ end
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
+
289
+ # Summary hash — safe to log or expose on a diagnostic endpoint.
290
+ def stats
291
+ tracked = @store.respond_to?(:size) ? @store.size : :external
292
+ { tracked_ips: tracked, max_requests: @max_requests, window_seconds: @window_seconds }
293
+ end
294
+ end
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # FilePayload — wraps an uploaded file (multipart or raw) with a clean API.
298
+ # ---------------------------------------------------------------------------
299
+ class FilePayload
300
+ attr_reader :filename, :content_type, :size, :tempfile
301
+
302
+ def initialize(tempfile:, filename:, content_type:)
303
+ @tempfile = tempfile
304
+ @filename = filename.to_s
305
+ @content_type = content_type.to_s
306
+ @size = tempfile.respond_to?(:size) ? tempfile.size : (tempfile.respond_to?(:length) ? tempfile.length : tempfile.to_s.bytesize)
307
+ end
308
+
309
+ # Returns the raw binary content of the file as a String (encoding: BINARY).
310
+ def read
311
+ if @tempfile.respond_to?(:read)
312
+ @tempfile.rewind if @tempfile.respond_to?(:rewind)
313
+ @tempfile.read
314
+ else
315
+ @tempfile.to_s.dup.force_encoding(Encoding::BINARY)
316
+ end
317
+ end
318
+
319
+ # Returns the file content encoded as a Base64 string (no newlines).
320
+ def to_base64
321
+ Base64.strict_encode64(read)
322
+ end
323
+
324
+ # Returns the file content encoded as a lowercase hexadecimal string.
325
+ def to_hex
326
+ read.unpack1('H*')
327
+ end
328
+
329
+ # Saves the uploaded content to *dest_path* on disk. Returns dest_path.
330
+ def save_to(dest_path)
331
+ dir = File.dirname(dest_path)
332
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
333
+ File.open(dest_path, 'wb') { |f| f.write(read) }
334
+ dest_path
335
+ end
336
+
337
+ # Convenience: the file extension derived from the original filename.
338
+ def extension
339
+ File.extname(@filename).downcase
340
+ end
341
+
342
+ # Human-friendly summary — safe to include in JSON responses.
343
+ def to_h
344
+ {
345
+ filename: @filename,
346
+ content_type: @content_type,
347
+ size: @size,
348
+ extension: extension
349
+ }
350
+ end
351
+
352
+ def inspect
353
+ "#<GRApiManager::FilePayload filename=#{@filename.inspect} " \
354
+ "content_type=#{@content_type.inspect} size=#{@size}>"
355
+ end
356
+ end
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # BodyParser — detects Content-Type and returns an appropriate parsed result.
360
+ # ---------------------------------------------------------------------------
361
+ module BodyParser
362
+
363
+ BINARY_MIME_PREFIXES = %w[
364
+ image/ video/ audio/ application/pdf application/msword
365
+ application/vnd. application/zip application/x-tar
366
+ application/x-rar application/octet-stream
367
+ ].freeze
368
+
369
+ # Returns a Hash of parsed body values.
370
+ def self.parse(request)
371
+ content_type = (request.content_type || '').split(';').first.to_s.strip.downcase
372
+
373
+ case content_type
374
+ when 'application/json'
375
+ parse_json(request)
376
+
377
+ when 'multipart/form-data'
378
+ parse_multipart(request)
379
+
380
+ when 'application/x-www-form-urlencoded'
381
+ {}
382
+
383
+ when 'text/plain'
384
+ text = request.body.read.to_s.force_encoding(Encoding::UTF_8)
385
+ { _raw_text: text }
386
+
387
+ else
388
+ if binary_content_type?(content_type)
389
+ parse_raw_binary(request, content_type)
390
+ else
391
+ parse_json(request) rescue {}
392
+ end
393
+ end
394
+ end
395
+
396
+ private_class_method
397
+
398
+ def self.parse_json(request)
399
+ body = request.body.read.to_s
400
+ return {} if body.strip.empty?
401
+ JSON.parse(body, symbolize_names: true)
402
+ rescue JSON::ParserError => e
403
+ raise ArgumentError, "Invalid JSON body: #{e.message}"
404
+ end
405
+
406
+ def self.parse_multipart(request)
407
+ result = {}
408
+ files = {}
409
+
410
+ request.params.each do |key, value|
411
+ sym = key.to_sym
412
+
413
+ if value.is_a?(Hash) && value.key?(:tempfile)
414
+ files[sym] = FilePayload.new(
415
+ tempfile: value[:tempfile],
416
+ filename: value[:filename] || key,
417
+ content_type: value[:type] || 'application/octet-stream'
418
+ )
419
+ elsif value.is_a?(Array)
420
+ files[sym] = value.map do |v|
421
+ if v.is_a?(Hash) && v.key?(:tempfile)
422
+ FilePayload.new(
423
+ tempfile: v[:tempfile],
424
+ filename: v[:filename] || key,
425
+ content_type: v[:type] || 'application/octet-stream'
426
+ )
427
+ else
428
+ v
429
+ end
430
+ end
431
+ else
432
+ result[sym] = value
433
+ end
434
+ end
435
+
436
+ result[:_files] = files unless files.empty?
437
+ result
438
+ end
439
+
440
+ def self.parse_raw_binary(request, content_type)
441
+ raw = request.body.read
442
+ return {} if raw.nil? || raw.empty?
443
+
444
+ disposition = request.env['HTTP_CONTENT_DISPOSITION'] || ''
445
+ filename = disposition[/filename="?([^";]+)"?/, 1] || "upload#{ext_for(content_type)}"
446
+
447
+ io = StringIO.new(raw.force_encoding(Encoding::BINARY))
448
+
449
+ payload = FilePayload.new(
450
+ tempfile: io,
451
+ filename: filename,
452
+ content_type: content_type
453
+ )
454
+
455
+ { _raw_binary: payload }
456
+ end
457
+
458
+ def self.binary_content_type?(ct)
459
+ BINARY_MIME_PREFIXES.any? { |prefix| ct.start_with?(prefix) }
460
+ end
461
+
462
+ def self.ext_for(content_type)
463
+ {
464
+ 'image/jpeg' => '.jpg',
465
+ 'image/png' => '.png',
466
+ 'image/gif' => '.gif',
467
+ 'image/webp' => '.webp',
468
+ 'image/svg+xml' => '.svg',
469
+ 'image/bmp' => '.bmp',
470
+ 'video/mp4' => '.mp4',
471
+ 'video/webm' => '.webm',
472
+ 'audio/mpeg' => '.mp3',
473
+ 'audio/wav' => '.wav',
474
+ 'application/pdf' => '.pdf',
475
+ 'application/msword' => '.doc',
476
+ 'application/zip' => '.zip',
477
+ 'application/x-tar' => '.tar',
478
+ 'application/octet-stream' => '.bin'
479
+ }.fetch(content_type, '.bin')
480
+ end
481
+ end
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
+
536
+ # ---------------------------------------------------------------------------
537
+ # Server — the public-facing DSL.
538
+ # ---------------------------------------------------------------------------
6
539
  class Server
7
- attr_reader :app_class
540
+ attr_reader :app_class, :rate_limiter, :jwt_secret
8
541
 
9
542
  # Initializes the server configuration.
10
- def initialize(port: nil, bearer_token: nil, permitted_hosts: [], prefix: '')
11
- @port = port || ENV['PORT'] || 4000
12
- @token = bearer_token || ENV['API_TOKEN']
13
- @permitted_hosts = permitted_hosts.empty? ? [] : permitted_hosts
14
- @prefix = prefix
15
-
543
+ #
544
+ # Options:
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)
556
+ def initialize(
557
+ port: nil,
558
+ bearer_token: nil,
559
+ jwt_secret: nil,
560
+ permitted_hosts: [],
561
+ prefix: '',
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
568
+ )
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
582
+
16
583
  @app_class = Class.new(Sinatra::Base) do
17
-
584
+
18
585
  # Logs HTTP requests with status-based color coding.
19
- def log_request(method, path, params, status_code)
586
+ def log_request(method, path, status_code)
20
587
  color = status_code.between?(200, 299) ? "\e[32m" : "\e[31m"
21
588
  puts "[#{Time.now.strftime('%H:%M:%S')}] #{color}#{method} #{path} - #{status_code}\e[0m"
22
589
  end
@@ -24,11 +591,12 @@ module GRApiManager
24
591
  # Casts string URL parameters to native Ruby types (Integer, Float, Boolean).
25
592
  def smart_parse(hash)
26
593
  hash.transform_values do |val|
594
+ next val unless val.is_a?(String)
27
595
  case val
28
- when 'true' then true
29
- when 'false' then false
30
- when /^[0-9]+$/ then val.to_i
31
- when /^[0-9]+\.[0-9]+$/ then val.to_f
596
+ when 'true' then true
597
+ when 'false' then false
598
+ when /^-?\d+$/ then val.to_i
599
+ when /^-?\d+\.\d+$/ then val.to_f
32
600
  else val
33
601
  end
34
602
  end
@@ -38,22 +606,74 @@ module GRApiManager
38
606
  configure_app
39
607
  end
40
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
+
41
628
  private
42
629
 
43
- # Sets up Sinatra environment, CORS policies, and global error handlers.
630
+ # Sets up Sinatra environment, CORS policies, body size limit, rate limiting, and global error handlers.
44
631
  def configure_app
45
- app = @app_class
46
- app.set :port, @port
47
- app.set :bind, '0.0.0.0'
48
- app.set :token, @token
49
- app.set :show_exceptions, false
632
+ app = @app_class
633
+ max_body = @max_body_size
634
+ rate_limiter = @rate_limiter
635
+ trust_proxy_headers = @trust_proxy_headers
636
+
637
+ app.set :port, @port
638
+ app.set :bind, '0.0.0.0'
639
+ app.set :token, @token
640
+ app.set :jwt_secret, @jwt_secret
641
+ app.set :dev_mode, @dev_mode
642
+ app.set :show_exceptions, false
643
+ app.set :raise_errors, false
644
+ app.set :dump_errors, false
50
645
  app.set :host_authorization, { permitted_hosts: @permitted_hosts }
646
+ app.enable :static
51
647
 
52
- # Enable broad CORS and handle preflight requests.
53
648
  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'
649
+ headers 'Access-Control-Allow-Origin' => '*',
650
+ 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
651
+ 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, Content-Disposition'
652
+
653
+ # Rate limiting — checked before anything else.
654
+ if rate_limiter
655
+ client_ip = trust_proxy_headers ? GRApiManager.extract_client_ip(request) : request.ip
656
+ unless rate_limiter.allow?(client_ip)
657
+ remaining_reset = rate_limiter.window_seconds
658
+ headers 'Retry-After' => remaining_reset.to_s,
659
+ 'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
660
+ 'X-RateLimit-Remaining' => '0',
661
+ 'X-RateLimit-Reset' => (Time.now.to_i + remaining_reset).to_s
662
+ halt 429, { error: "Too many requests", retry_after_seconds: remaining_reset }.to_json
663
+ end
664
+ # Add rate-limit headers on allowed requests too.
665
+ headers 'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
666
+ 'X-RateLimit-Remaining' => rate_limiter.remaining(client_ip).to_s
667
+ end
668
+
669
+ # Body size limit (skip for read-only / headerless verbs).
670
+ unless %w[GET DELETE OPTIONS HEAD].include?(request.request_method)
671
+ content_length = request.content_length.to_i
672
+ if content_length > max_body
673
+ halt 413, { error: "Payload too large", max_bytes: max_body }.to_json
674
+ end
675
+ end
676
+
57
677
  content_type :json
58
678
  end
59
679
 
@@ -61,87 +681,174 @@ module GRApiManager
61
681
  halt 200
62
682
  end
63
683
 
64
- # JSON formatted 404 response.
65
684
  app.not_found do
66
685
  status 404
686
+ content_type :json
67
687
  { error: "Endpoint not found", path: request.path_info }.to_json
68
688
  end
69
689
 
70
- # JSON formatted 500 response.
71
690
  app.error do
72
691
  e = env['sinatra.error']
73
692
  status 500
74
- { error: "Internal Server Error", details: e.message }.to_json
693
+ content_type :json
694
+ if settings.dev_mode
695
+ { error: "Internal Server Error", details: e&.message,
696
+ class: e&.class&.to_s, backtrace: e&.backtrace&.first(15) }.to_json
697
+ else
698
+ { error: "Internal Server Error", details: e&.message }.to_json
699
+ end
75
700
  end
76
701
  end
77
702
 
78
703
  public
79
704
 
80
- # Dynamically generate routing methods (get, post, put, delete).
81
- %w[get post put delete].each do |verb|
705
+ # Dynamically generate routing methods (get, post, put, patch, delete).
706
+ %w[get post put patch delete].each do |verb|
82
707
  define_method(verb) do |path, options = {}, &block|
83
708
  register_route(verb, path, options, &block)
84
709
  end
85
710
  end
86
711
 
87
- # Core routing logic: auth validation, param parsing, and block execution.
712
+ # Core routing logic: auth validation, body parsing, param merging, validation, execution.
88
713
  def register_route(verb, path, options = {}, &block)
89
- verb = verb.to_s.upcase
90
- require_auth = options.fetch(:auth, true)
91
- required_params = options.fetch(:requires, [])
92
-
714
+ verb_up = verb.to_s.upcase
715
+ require_auth = options.fetch(:auth, true)
716
+ required_params = options.fetch(:requires, nil)
717
+
93
718
  # Construct the full path with the optional prefix.
94
719
  full_path = File.join('/', @prefix.to_s, path.to_s).gsub(%r{/+}, '/')
95
720
 
96
721
  handler = proc do
97
-
98
722
  # 1. Authentication check
723
+ jwt_user = nil
99
724
  if require_auth
100
725
  auth_header = request.env["HTTP_AUTHORIZATION"]
101
726
  halt 401, { error: "Token required. Format: 'Bearer <token>'" }.to_json if auth_header.nil?
102
- halt 403, { error: "Invalid token" }.to_json if auth_header.split(" ").last != settings.token
103
- end
104
727
 
105
- # 2. Body parsing (for POST/PUT requests)
106
- parsed_body = {}
107
- if ['POST', 'PUT'].include?(verb)
108
- body_data = request.body.read.to_s
109
- unless body_data.empty?
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
110
733
  begin
111
- parsed_body = JSON.parse(body_data, symbolize_names: true)
112
- rescue JSON::ParserError
113
- halt 400, { error: "Invalid JSON body" }.to_json
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
114
751
  end
115
752
  end
116
753
  end
117
754
 
118
- # 3. Merge query parameters with parsed JSON body
119
- all_params = smart_parse(params).merge(parsed_body)
755
+ # 2. Body parsing smart detection based on Content-Type
756
+ parsed_body = {}
757
+ if %w[POST PUT PATCH].include?(verb_up)
758
+ begin
759
+ parsed_body = GRApiManager::BodyParser.parse(request)
760
+ rescue ArgumentError => e
761
+ halt 400, { error: e.message }.to_json
762
+ end
763
+ end
764
+
765
+ # 3. Merge query/path parameters with parsed body.
766
+ url_params = smart_parse(params.reject { |_, v| v.is_a?(Hash) && v.key?(:tempfile) })
767
+ all_params = url_params.merge(parsed_body)
120
768
 
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
- if missing.any?
124
- status 400
125
- log_request(verb, full_path, all_params, 400)
126
- next { error: "Missing required parameters", required: missing }.to_json
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
773
+ end
774
+
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
127
794
  end
128
795
 
129
796
  # 5. Execute user-defined block
130
797
  result = instance_exec(all_params, &block)
131
- log_request(verb, full_path, all_params, response.status)
132
- result.to_json
798
+ log_request(verb_up, full_path, response.status)
799
+
800
+ result.is_a?(String) ? result : result.to_json
133
801
  end
134
802
 
135
803
  @app_class.send(verb.downcase, full_path, &handler)
136
804
  end
137
805
 
138
- # Starts the Sinatra server with a custom GR banner.
139
- def run!
806
+ # Starts the Sinatra server.
807
+ def run!(workers: nil, threads: '2:8')
808
+ w = (workers || ENV.fetch('WEB_CONCURRENCY', 2)).to_i
809
+ min_t, max_t = threads.to_s.split(':').map(&:to_i)
810
+ max_t ||= min_t
811
+ mb = (@max_body_size.to_f / 1_048_576).round(1)
812
+
813
+ # Use Puma as the application server for concurrency.
814
+ @app_class.set :server, :puma
815
+ @app_class.set :server_settings, {
816
+ workers: w,
817
+ min_threads: min_t,
818
+ max_threads: max_t
819
+ }
820
+
821
+ # Background thread to purge stale rate-limit entries.
822
+ if @rate_limiter
823
+ rl = @rate_limiter
824
+ Thread.new do
825
+ loop do
826
+ sleep rl.window_seconds * 2
827
+ rl.cleanup!
828
+ end
829
+ end
830
+ end
831
+
832
+ rl_info = if @rate_limiter
833
+ "#{@rate_limiter.max_requests} req / #{@rate_limiter.window_seconds}s per IP"
834
+ else
835
+ 'Disabled'
836
+ end
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
+
140
843
  puts "============================================="
141
844
  puts " GR API MANAGER STARTED"
142
- puts " Port : #{@port}"
143
- puts " Auth : #{@token ? 'Enabled' : 'Public'}"
144
- puts " Prefix : #{@prefix.empty? ? '/' : @prefix}"
845
+ puts " Port : #{@port}"
846
+ puts " Auth : #{auth_display}"
847
+ puts " Prefix : #{@prefix.empty? ? '/' : @prefix}"
848
+ puts " Max Body : #{mb} MB"
849
+ puts " Workers : #{w} | Threads: #{min_t}:#{max_t}"
850
+ puts " Rate Limit: #{rl_info}"
851
+ puts " Dev Mode : #{@dev_mode ? 'ON ⚠️ (disable in production)' : 'Off'}"
145
852
  puts "============================================="
146
853
  @app_class.run!
147
854
  end