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, jwt_secret: nil, permitted_hosts: [], prefix: '', max_body_size: GRApiManager.mb(50), dev_mode: false, rate_limit: nil, rate_limit_window: 60, rate_limit_store: nil, trust_proxy_headers: true) ⇒ 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'])
jwt_secret:           String   – Secret key for signing/decoding JWTs (default: ENV['JWT_SECRET'])
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)
rate_limit_store:     Object   – custom store object (default: MemoryStore)
trust_proxy_headers:  Boolean  – inspect Cloudflare/X-Real-IP/X-Forwarded-For headers (default: true)


556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
# File 'lib/gr_api_manager.rb', line 556

def initialize(
  port: nil,
  bearer_token: nil,
  jwt_secret: nil,
  permitted_hosts: [],
  prefix: '',
  max_body_size:        GRApiManager.mb(50),
  dev_mode:             false,
  rate_limit:           nil,
  rate_limit_window:    60,
  rate_limit_store:     nil,
  trust_proxy_headers:  true
)
  @port                = port || ENV['PORT'] || 4000
  @token               = bearer_token || ENV['API_TOKEN']
  @jwt_secret          = jwt_secret || ENV['JWT_SECRET']
  @permitted_hosts     = permitted_hosts.empty? ? [] : permitted_hosts
  @prefix              = prefix
  @max_body_size       = max_body_size
  @dev_mode            = dev_mode
  @trust_proxy_headers = trust_proxy_headers
  @rate_limiter        = rate_limit ? GRApiManager::RateLimiter.new(
                           max_requests:   rate_limit,
                           window_seconds: rate_limit_window,
                           store:          rate_limit_store
                         ) : 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).
    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.



540
541
542
# File 'lib/gr_api_manager.rb', line 540

def app_class
  @app_class
end

#jwt_secretObject (readonly)

Returns the value of attribute jwt_secret.



540
541
542
# File 'lib/gr_api_manager.rb', line 540

def jwt_secret
  @jwt_secret
end

#rate_limiterObject (readonly)

Returns the value of attribute rate_limiter.



540
541
542
# File 'lib/gr_api_manager.rb', line 540

def rate_limiter
  @rate_limiter
end

Instance Method Details

#group(prefix = '', options = {}, &block) ⇒ Object

Groups routes under a common prefix with inherited options.



622
623
624
625
626
# File 'lib/gr_api_manager.rb', line 622

def group(prefix = '', options = {}, &block)
  route_group = RouteGroup.new(self, prefix, options)
  block.call(route_group) if block
  route_group
end

#jwt_decode(token) ⇒ Object

Decodes a JWT token using the configured jwt_secret.



616
617
618
619
# File 'lib/gr_api_manager.rb', line 616

def jwt_decode(token)
  raise "No jwt_secret configured for this server" unless @jwt_secret
  GRApiManager::JWT.decode(token, @jwt_secret)
end

#jwt_encode(payload, exp: nil) ⇒ Object

Encodes a payload into a JWT token using the configured jwt_secret.



610
611
612
613
# File 'lib/gr_api_manager.rb', line 610

def jwt_encode(payload, exp: nil)
  raise "No jwt_secret configured for this server" unless @jwt_secret
  GRApiManager::JWT.encode(payload, @jwt_secret, exp: exp)
end

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

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



713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
# File 'lib/gr_api_manager.rb', line 713

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

  # 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
    jwt_user = nil
    if require_auth
      auth_header = request.env["HTTP_AUTHORIZATION"]
      halt 401, { error: "Token required. Format: 'Bearer <token>'" }.to_json if auth_header.nil?

      raw_token = auth_header.split(" ").last

      if require_auth == :jwt || (require_auth == true && settings.jwt_secret && settings.token.nil?)
        # JWT authentication mode
        halt 500, { error: "Server error: jwt_secret is not configured" }.to_json unless settings.jwt_secret
        begin
          jwt_user = GRApiManager::JWT.decode(raw_token, settings.jwt_secret)
        rescue GRApiManager::JWT::DecodeError => e
          halt 401, { error: "Invalid token: #{e.message}" }.to_json
        end
      else
        # Static Bearer Token mode
        if raw_token != settings.token
          # Fallback: if jwt_secret is set, try JWT decoding
          if settings.jwt_secret
            begin
              jwt_user = GRApiManager::JWT.decode(raw_token, settings.jwt_secret)
            rescue GRApiManager::JWT::DecodeError
              halt 403, { error: "Invalid token" }.to_json
            end
          else
            halt 403, { error: "Invalid token" }.to_json
          end
        end
      end
    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  = smart_parse(params.reject { |_, v| v.is_a?(Hash) && v.key?(:tempfile) })
    all_params  = url_params.merge(parsed_body)

    # Inyect JWT payload if authenticated via JWT
    if jwt_user
      all_params[:current_user] = jwt_user
      all_params[:jwt_payload]  = jwt_user
    end

    # 4. Declarative parameter validation (Array of keys or Hash schema)
    if required_params.is_a?(Hash)
      is_valid, errors = GRApiManager::Validator.validate(all_params, required_params)
      unless is_valid
        status 400
        log_request(verb_up, full_path, 400)
        next { error: "Validation failed", errors: errors }.to_json
      end
    elsif required_params.is_a?(Array) && required_params.any?
      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
    end

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

    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.



807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
# File 'lib/gr_api_manager.rb', line 807

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.
  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

  auth_info = []
  auth_info << "Bearer Token" if @token
  auth_info << "JWT (HS256)" if @jwt_secret
  auth_display = auth_info.empty? ? "Public (no token)" : auth_info.join(' + ')

  puts "============================================="
  puts "  GR API MANAGER STARTED"
  puts "  Port      : #{@port}"
  puts "  Auth      : #{auth_display}"
  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