RFC/Tracking: Detach Options subclasses into explicit OOP (BaseOptions + OptionsLike), keep legacy Options

Summary

  • Keep Faraday::Options as-is for backward compatibility.

  • Detach existing subclasses (ConnectionOptions, RequestOptions, SSLOptions, ProxyOptions, Env) into explicit OOP classes that do not inherit from Options.

  • Introduce:

  • Faraday::OptionsLike (marker module) to identify “options-like” objects.

  • Faraday::BaseOptions (abstract superclass) that centralizes .from/update/merge!/merge/deep_dup/to_hash/inspect and nested coercion via constants.

  • Drop legacy ergonomics for detached subclasses (Struct indexing, fetch, each_key, .options macro, .memoized macro). Preserve Env’s hash-like []/[]= since middleware uses it.

  • Convert classes one-by-one: ProxyOptions → RequestOptions → SSLOptions → ConnectionOptions → Env.

Main decisions

  • Inheritance + marker: BaseOptions reduces duplication and drift for correctness-sensitive logic (nested coercion/merge/deep-dup). OptionsLike provides a stable way to integrate with Utils.deep_merge! and any duck-typed interop.

  • Back-compat boundary: Faraday::Options remains unchanged. New classes’ .from accept Hash and legacy Options (via to_hash). Deep-merge continues to work on both legacy and new options classes.

  • Ergonomics: For internal code we drop Struct-like features. Env keeps []/[]=.

Short code examples

OptionsLike and BaseOptions

module Faraday
  module OptionsLike; end

  class BaseOptions
    include OptionsLike

    MEMBERS = [].freeze        # override in subclasses
    COERCIONS = {}.freeze      # key => Class or Proc

    def self.from(value)
      case value
      when nil
        new
      when self
        value
      when OptionsLike
        new.update(value.to_hash)
      when Hash
        new.update(value)
      else
        raise ArgumentError, "unsupported options: #{value.class}"
      end
    end

    def initialize(**attrs)
      attrs.each { |k, v| public_send("#{k}=", coerce(k, v)) }
    end

    def update(hash_like)
      hash_like.each { |k, v| public_send("#{k}=", coerce(k, v)) }
      self
    end

    def merge!(other)
      other.each do |k, v|
        next if v.nil?
        cur = public_send(k)
        newv = coerce(k, v)
        if cur.is_a?(OptionsLike) && newv.is_a?(OptionsLike)
          cur.merge!(newv.to_hash)
        else
          public_send("#{k}=", newv)
        end
      end
      self
    end

    def merge(other)
      self.class.from(to_hash).merge!(other)
    end

    def deep_dup
      self.class.from(to_hash)
    end

    def to_hash
      self.class::MEMBERS.each_with_object({}) do |k, h|
        v = public_send(k)
        next if v.nil?
        h[k] = v.is_a?(OptionsLike) ? v.to_hash : v
      end
    end

    def inspect
      pairs = to_hash.map { |k, v| "#{k}=#{v.inspect}" }
      "#<#{self.class} #{pairs.join(', ')}>"
    end

    private

    def coerce(key, value)
      return value if value.nil?
      coercer = self.class::COERCIONS[key.to_sym]
      case coercer
      when Class then coercer.from(value)
      when Proc  then coercer.call(value)
      else            value
      end
    end
  end
end

ProxyOptions (detached)

class Faraday::ProxyOptions < Faraday::BaseOptions
  MEMBERS = [:uri, :user, :password].freeze
  attr_accessor(*MEMBERS)

  COERCIONS = {
    uri: ->(v) do
      case v
      when String
        v = "http://#{v}" unless v.include?('://')
        Faraday::Utils.URI(v)
      when URI
        v
      else
        v
      end
    end
  }.freeze

  def user
    @user || (uri && Faraday::Utils.unescape(uri.user))
  end

  def password
    @password || (uri && Faraday::Utils.unescape(uri.password))
  end
end

Utils.deep_merge! change

# lib/faraday/utils.rb
# Treat OptionsLike like Options when deep-merging nested structures
if value.is_a?(Hash) && (target_value.is_a?(Hash) || target_value.is_a?(Faraday::OptionsLike))
  target[key] = deep_merge(target_value, value)
else
  target[key] = value
end

Incremental rollout

1) Foundation: add OptionsLike + BaseOptions; update Utils.deep_merge!. 2) Convert ProxyOptions (smallest surface, minimal coupling). 3) Convert RequestOptions (ensure proxy coercion and deep_merge! semantics). 4) Convert SSLOptions (larger surface; explicit lazy cert_store). 5) Convert ConnectionOptions (nested request/ssl, builder_class default, new_builder). 6) Convert Env last (preserve []/[]= and to_hash; confirm middleware compatibility). 7) Tests/docs: expand tests for nested coercion, nil-preserving merge, deep_dup, to_hash; update docs.

Tasks (use “Create issue from task list”)

  • [ ] Foundation: Introduce OptionsLike and BaseOptions; update Utils.deep_merge!

  • Files: add lib/faraday/options_like.rb, lib/faraday/base_options.rb; modify lib/faraday/utils.rb

  • Tests: BaseOptions unit tests; deep_merge! tests for OptionsLike

  • Docs: brief section on OptionsLike/BaseOptions

  • Research: grep for is_a?(Options) checks and update where needed

  • [ ] Convert ProxyOptions to BaseOptions (preserve behavior; drop Struct ergonomics)

  • Files: lib/faraday/options/proxy_options.rb

  • Tests: string/URI coercion, empty string => nil (via RequestOptions#proxy=), user/password derivation

  • Research: find ProxyOptions usages and delegators (scheme/host/port/path)

  • [ ] Convert RequestOptions to BaseOptions (keep proxy coercion and stream_response?)

  • Files: lib/faraday/options/request_options.rb

  • Tests: nested proxy coercion, deep_merge!, to_hash

  • Research: usages of []/[]= on RequestOptions; replace with explicit accessors

  • [ ] Convert SSLOptions to BaseOptions (preserve lazy cert_store and semantics)

  • Files: lib/faraday/options/ssl_options.rb

  • Tests: coercion, deep_dup, to_hash, lazy cert_store

  • Research: adapter interactions with SSLOptions

  • [ ] Convert ConnectionOptions to BaseOptions (coerce request/ssl; default builder_class)

  • Files: lib/faraday/options/connection_options.rb, lib/faraday.rb

  • Tests: Faraday.new/default_connection_options, deep_merge!, builder behavior

  • Research: usages of members/values/[]; migrate to explicit access

  • [ ] Convert Env (last; preserve middleware hash-like API)

  • Files: lib/faraday/options/env.rb

  • Tests: env and middleware integration

  • Research: env usages; avoid breaking third-party middleware

  • [ ] Optional: Deprecate Options.memoized macro (new classes don’t use it)

  • Files: lib/faraday/options.rb

  • Tests/Docs: deprecation notice and changelog entry