Class: GRApiManager::Server

Inherits:
Object
  • Object
show all
Defined in:
lib/gr_api_manager.rb

Overview


Server — the public-facing DSL.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(port: nil, bearer_token: nil, permitted_hosts: [], prefix: '', max_body_size: GRApiManager.mb(50), dev_mode: false, rate_limit: nil, rate_limit_window: 60) ⇒ Server

Initializes the server configuration.

Options:

port:               Integer  – listening port (default: ENV['PORT'] || 4000)
bearer_token:       String   – Bearer token for auth (default: ENV['API_TOKEN'])
permitted_hosts:    Array    – host allowlist; empty = allow all
prefix:             String   – route prefix, e.g. '/api/v1'
max_body_size:      Integer  – maximum accepted body in bytes (default: 50 MB)
dev_mode:           Boolean  – show full stack traces on 500 (default: false)
rate_limit:         Integer  – max requests per IP per window (nil = disabled)
rate_limit_window:  Integer  – sliding window in seconds (default: 60)


293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/gr_api_manager.rb', line 293

def initialize(
  port: nil,
  bearer_token: nil,
  permitted_hosts: [],
  prefix: '',
  max_body_size:      GRApiManager.mb(50),
  dev_mode:           false,
  rate_limit:         nil,
  rate_limit_window:  60
)
  @port            = port || ENV['PORT'] || 4000
  @token           = bearer_token || ENV['API_TOKEN']
  @permitted_hosts = permitted_hosts.empty? ? [] : permitted_hosts
  @prefix          = prefix
  @max_body_size   = max_body_size
  @dev_mode        = dev_mode
  @rate_limiter    = rate_limit ? GRApiManager::RateLimiter.new(
                       max_requests:   rate_limit,
                       window_seconds: rate_limit_window
                     ) : nil

  @app_class = Class.new(Sinatra::Base) do

    # Logs HTTP requests with status-based color coding.
    def log_request(method, path, status_code)
      color = status_code.between?(200, 299) ? "\e[32m" : "\e[31m"
      puts "[#{Time.now.strftime('%H:%M:%S')}] #{color}#{method} #{path} - #{status_code}\e[0m"
    end

    # Casts string URL parameters to native Ruby types (Integer, Float, Boolean).
    # Leaves values untouched if they are already non-String (e.g. FilePayload).
    def smart_parse(hash)
      hash.transform_values do |val|
        next val unless val.is_a?(String)
        case val
        when 'true'            then true
        when 'false'           then false
        when /^\d+$/           then val.to_i
        when /^\d+\.\d+$/      then val.to_f
        else val
        end
      end
    end
  end

  configure_app
end

Instance Attribute Details

#app_classObject (readonly)

Returns the value of attribute app_class.



280
281
282
# File 'lib/gr_api_manager.rb', line 280

def app_class
  @app_class
end

Instance Method Details

#register_route(verb, path, options = {}, &block) ⇒ Object

Core routing logic: auth validation, body parsing, param merging, validation, execution.

Supported body formats (auto-detected via Content-Type):

application/json             – standard JSON body
multipart/form-data          – form fields + file uploads
application/octet-stream     – raw binary stream
image/*, video/*, audio/*    – raw binary media
application/pdf, etc.        – raw binary document
text/plain                   – plain text body

Inside your block, params will contain:

:_files      => { field: FilePayload }   – for multipart uploads
:_raw_binary => FilePayload              – for raw binary/media bodies
:_raw_text   => String                   – for text/plain bodies

Route options:

auth:     Boolean – require Bearer Token (default: true)
requires: Array   – required parameter keys [:name, :email, ...]


437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/gr_api_manager.rb', line 437

def register_route(verb, path, options = {}, &block)
  verb_up        = verb.to_s.upcase
  require_auth   = options.fetch(:auth, true)
  required_params = options.fetch(:requires, [])

  # Construct the full path with the optional prefix.
  full_path = File.join('/', @prefix.to_s, path.to_s).gsub(%r{/+}, '/')

  handler = proc do
    # 1. Authentication check
    if require_auth
      auth_header = request.env["HTTP_AUTHORIZATION"]
      halt 401, { error: "Token required. Format: 'Bearer <token>'" }.to_json if auth_header.nil?
      halt 403, { error: "Invalid token" }.to_json if auth_header.split(" ").last != settings.token
    end

    # 2. Body parsing — smart detection based on Content-Type
    parsed_body = {}
    if %w[POST PUT PATCH].include?(verb_up)
      begin
        parsed_body = GRApiManager::BodyParser.parse(request)
      rescue ArgumentError => e
        halt 400, { error: e.message }.to_json
      end
    end

    # 3. Merge query/path parameters with parsed body.
    #    URL params go through smart_parse; body values are left as-is
    #    (so FilePayload objects, arrays, etc. are preserved).
    url_params  = smart_parse(params.reject { |_, v| v.is_a?(Hash) && v.key?(:tempfile) })
    all_params  = url_params.merge(parsed_body)

    # 4. Declarative parameter validation (skips special _ keys and FilePayload values).
    missing = required_params.select do |p|
      val = all_params[p.to_sym]
      val.nil? || (val.is_a?(String) && val.strip.empty?)
    end

    if missing.any?
      status 400
      log_request(verb_up, full_path, 400)
      next { error: "Missing required parameters", required: missing }.to_json
    end

    # 5. Execute user-defined block
    result = instance_exec(all_params, &block)
    log_request(verb_up, full_path, response.status)

    # If the block returned a String (e.g. already rendered binary data),
    # pass it through unchanged. Otherwise serialize to JSON.
    result.is_a?(String) ? result : result.to_json
  end

  @app_class.send(verb.downcase, full_path, &handler)
end

#run!(workers: nil, threads: '2:8') ⇒ Object

Starts the Sinatra server.

Options:

workers: Integer – Puma worker processes (default: ENV['WEB_CONCURRENCY'] || 2)
threads: String  – min:max thread count per worker (default: '2:8')

For sustained high traffic, run behind Nginx as a reverse proxy. See the README section "High Traffic & Concurrency" for production tuning.



501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
# File 'lib/gr_api_manager.rb', line 501

def run!(workers: nil, threads: '2:8')
  w            = (workers || ENV.fetch('WEB_CONCURRENCY', 2)).to_i
  min_t, max_t = threads.to_s.split(':').map(&:to_i)
  max_t        ||= min_t
  mb           = (@max_body_size.to_f / 1_048_576).round(1)

  # Use Puma as the application server for concurrency.
  @app_class.set :server, :puma
  @app_class.set :server_settings, {
    workers:     w,
    min_threads: min_t,
    max_threads: max_t
  }

  # Background thread to purge stale rate-limit entries (prevents memory growth).
  if @rate_limiter
    rl = @rate_limiter
    Thread.new do
      loop do
        sleep rl.window_seconds * 2
        rl.cleanup!
      end
    end
  end

  rl_info = if @rate_limiter
              "#{@rate_limiter.max_requests} req / #{@rate_limiter.window_seconds}s per IP"
            else
              'Disabled'
            end

  puts "============================================="
  puts "  GR API MANAGER STARTED"
  puts "  Port      : #{@port}"
  puts "  Auth      : #{@token ? 'Enabled' : 'Public (no token)'}"
  puts "  Prefix    : #{@prefix.empty? ? '/' : @prefix}"
  puts "  Max Body  : #{mb} MB"
  puts "  Workers   : #{w}  |  Threads: #{min_t}:#{max_t}"
  puts "  Rate Limit: #{rl_info}"
  puts "  Dev Mode  : #{@dev_mode ? 'ON  ⚠️  (disable in production)' : 'Off'}"
  puts "============================================="
  @app_class.run!
end