Skip to content

Convert Env to BaseOptions (preserve middleware API) #1653

@iMacTia

Description

@iMacTia

Convert Env to BaseOptions (Preserve Middleware API)

Phase: 4 - Env Conversion (CRITICAL)
Release Target: v2.x.3
Tracking Issue: #1647
RFC: docs/options-detach-plan.md

⚠️ This is the most critical and complex conversion in the entire refactoring.

Overview

Convert Env to inherit from BaseOptions while absolutely preserving the hash-like [] and []= API that middleware depends on. Env is the heart of Faraday's request/response cycle and breaking it would break the entire ecosystem.

Why This Is Critical

  1. Middleware Everywhere: All middleware uses env[:url], env[:body], env[:request_headers], etc.
  2. Third-Party Ecosystem: External adapter and middleware gems depend on this API
  3. Custom Middleware: Users' custom middleware expects hash-like access
  4. No Warnings Possible: We can't deprecate this - must work perfectly

Current Structure

File: lib/faraday/options/env.rb (204 lines)

  • Inherits from Options
  • 13 core members: :method, :request_body, :url, :request, :request_headers, :ssl, :parallel_manager, :params, :response, :response_headers, :response_body, :status, :reason_phrase
  • Custom members via custom_members (dynamic attributes)
  • Hash-like access via [] and []=
  • Many helper methods:
    • body / body= (delegates to request_body or response_body)
    • success?, needs_body?, parse_body?
    • parallel?, stream_response?
    • in_member_set?, clear_body

New Structure

# frozen_string_literal: true

module Faraday
  class Env < BaseOptions
    MEMBERS = %i[
      method request_body url request request_headers ssl parallel_manager
      params response response_headers response_body status reason_phrase
    ].freeze
    
    COERCIONS = {
      request: RequestOptions,
      request_headers: Utils::Headers,
      response_headers: Utils::Headers,
      ssl: SSLOptions
    }.freeze

    attr_accessor :method, :request_body, :url, :request, :request_headers,
                  :ssl, :parallel_manager, :params, :response, :response_headers,
                  :response_body, :status, :reason_phrase

    def initialize(options = {})
      super(options)
      @custom_members = {}
      
      # Handle any custom members from initialization
      options.each do |key, value|
        @custom_members[key.to_sym] = value unless self.class::MEMBERS.include?(key.to_sym)
      end
    end

    # CRITICAL: Preserve hash-like access for middleware compatibility
    def [](key)
      key = key.to_sym
      if self.class::MEMBERS.include?(key)
        instance_variable_get(:"@#{key}")
      else
        @custom_members[key]
      end
    end

    def []=(key, value)
      key = key.to_sym
      if self.class::MEMBERS.include?(key)
        # Apply coercion if needed
        value = coerce(key, value) if self.class::COERCIONS[key]
        instance_variable_set(:"@#{key}", value)
      else
        @custom_members[key] = value
      end
    end

    # Body delegation
    def body
      response_body || request_body
    end

    def body=(value)
      if response
        @response_body = value
      else
        @request_body = value
      end
    end

    # Helper methods
    def success?
      status && status >= 200 && status < 300
    end

    def needs_body?
      !response && %i[post put patch].include?(method)
    end

    def parse_body?
      !!response && !response_body && !stream_response?
    end

    def parallel?
      !!parallel_manager
    end

    def stream_response?
      request&.stream_response?
    end

    def clear_body
      request_body = nil
      response_body = nil
    end

    def to_hash
      hash = super
      hash.merge!(@custom_members) if @custom_members
      hash
    end

    def deep_dup
      dup = super
      dup.instance_variable_set(:@custom_members, @custom_members.dup)
      dup
    end

    private

    def in_member_set?(key)
      self.class::MEMBERS.include?(key.to_sym) || @custom_members.key?(key.to_sym)
    end
  end
end

Implementation Tasks

Core Conversion

  • Update class to inherit from BaseOptions
  • Define MEMBERS and COERCIONS constants
  • Add explicit attr_accessor for all core members
  • PRESERVE [] and []= methods
  • Handle custom_members for dynamic attributes
  • Preserve all helper methods

Body Handling

  • Preserve body / body= delegation logic
  • Preserve clear_body method
  • Test request_body vs response_body scenarios

Coercion

  • Ensure request coerces to RequestOptions
  • Ensure request_headers / response_headers coerce to Utils::Headers
  • Ensure ssl coerces to SSLOptions

Custom Members

  • Test adding custom members via initialization
  • Test adding custom members via []=
  • Test accessing custom members via []
  • Ensure to_hash includes custom members

Testing

  • Update tests in spec/faraday/options/env_spec.rb
  • Extensive middleware compatibility tests
  • Test all helper methods
  • Test body delegation
  • Test custom members
  • Run FULL integration test suite
  • Test with real middleware stack

Critical Middleware Compatibility Tests

Must verify these common middleware patterns work:

# Pattern 1: Reading from env
env[:url]
env[:method]
env[:request_headers]

# Pattern 2: Writing to env
env[:custom_data] = 'value'
env[:request_headers]['Authorization'] = 'Bearer token'

# Pattern 3: Checking existence
env[:custom_key]  # returns nil if not present

# Pattern 4: Modifying request
env[:body] = JSON.generate(data)
env[:url].query = new_params

# Pattern 5: Reading response
env[:status]
env[:response_body]
env[:response_headers]

Files to Modify

  • lib/faraday/options/env.rb
  • spec/faraday/options/env_spec.rb

Files to Review (Extensive Integration Testing)

Acceptance Criteria

  • Env inherits from BaseOptions
  • ALL middleware hash-like access patterns work
  • All helper methods preserved
  • Body delegation works correctly
  • Custom members work (add/access via []/[]=)
  • Nested coercion works (request, ssl, headers)
  • All tests pass (unit + integration)
  • No breaking changes detected
  • Third-party middleware would continue working

Dependencies

Backward Compatibility

ABSOLUTE REQUIREMENT: All existing middleware must work unchanged.

Preserved APIs:

  • [] and []= for hash-like access
  • All helper methods (success?, needs_body?, etc.)
  • Body delegation
  • Custom members
  • Nested coercion

NO BREAKING CHANGES ALLOWED

Risk Assessment

Risk Level: CRITICAL - HIGHEST

Risks:

  1. Breaking middleware ecosystem
  2. Subtle behavior changes in hash access
  3. Custom members not working
  4. Body delegation edge cases
  5. Third-party adapter incompatibility

Mitigation:

  • EXTENSIVE testing before releasing
  • Test with multiple real middleware examples
  • Test with external adapters if possible
  • Consider beta release for community testing
  • Monitor issue reports closely after release

Testing Strategy

  1. Unit tests: All Env methods and edge cases
  2. Integration tests: Full request/response cycles
  3. Middleware tests: Real middleware stack scenarios
  4. Compatibility tests: Simulate third-party middleware patterns
  5. Performance tests: Ensure no significant slowdown

Recommended Testing Period

After implementation, run in testing/staging environment for at least 1-2 weeks before releasing v2.x.3 to production.

Consider:

  • Beta gem release (v2.x.3.beta1)
  • Community testing period
  • Monitor GitHub issues for any reports

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions