Skip to content

Add RFC document for Options architecture refactoring plan - #1644

Merged
iMacTia merged 2 commits into
mainfrom
copilot/add-options-detach-plan-doc
Nov 16, 2025
Merged

iMacTia merged 2 commits into
mainfrom
copilot/add-options-detach-plan-doc

Conversation

Copilot AI commented Oct 11, 2025 •

Copy link
Copy Markdown
Contributor

Overview

This PR adds a comprehensive RFC/planning document at docs/options-detach-plan.md that captures the plan to detach Options subclasses into explicit OOP while maintaining backward compatibility with the legacy Faraday::Options.

What's in the Document

The RFC document provides maintainers with a complete roadmap for modernizing Faraday's Options architecture:

Summary

  • Keep Faraday::Options unchanged for backward compatibility
  • Detach existing subclasses (ConnectionOptions, RequestOptions, SSLOptions, ProxyOptions, Env) into explicit OOP classes that don't inherit from Options
  • Introduce two new base components:
    • Faraday::OptionsLike - A marker module for identifying options-like objects
    • Faraday::BaseOptions - An abstract superclass centralizing common behavior (.from, update, merge!, merge, deep_dup, to_hash, inspect)

Code Examples

The document includes focused, working code examples:

  1. OptionsLike and BaseOptions - Shows the proposed base implementation with nested coercion support via MEMBERS and COERCIONS constants
  2. ProxyOptions (detached) - Demonstrates how a real Options subclass would be converted
  3. Utils.deep_merge! change - Shows the minimal change needed to support the new architecture

Incremental Rollout Plan

A 7-phase implementation strategy:

  1. Foundation (OptionsLike + BaseOptions + Utils.deep_merge!)
  2. Convert ProxyOptions (smallest, minimal coupling)
  3. Convert RequestOptions
  4. Convert SSLOptions
  5. Convert ConnectionOptions
  6. Convert Env (last, preserve middleware compatibility)
  7. Tests and documentation updates

Task List for GitHub Issues

The document includes a detailed task list with checkboxes that maintainers can use with GitHub's "Convert to issues" feature. Each task includes:

  • Specific files to modify
  • Testing requirements
  • Research items
  • Documentation needs

Purpose

This is a planning document only - no code changes are included. It serves as a reference for future implementation work and allows maintainers to:

  • Review and discuss the proposed architecture before implementation
  • Track progress by converting tasks to GitHub issues
  • Ensure consistency across the incremental rollout
  • Maintain a single source of truth for the refactoring plan

Related

This RFC addresses the need to modernize the Options architecture while preserving backward compatibility, reducing code duplication, and improving maintainability of correctness-sensitive logic like nested coercion and deep merging.

Original prompt

Create a planning/RFC document that captures the plan to detach Options subclasses into explicit OOP while keeping the legacy Faraday::Options for backward compatibility. Add a new Markdown file at docs/options-detach-plan.md with the content below. This document should include: a summary, the main decisions, short focused code examples (OptionsLike, BaseOptions, ProxyOptions, and Utils.deep_merge! snippet), an incremental rollout plan, and a task list that maintainers can “Convert to issues” from the GitHub UI.

File: docs/options-detach-plan.md

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

This pull request was created as a result of the following prompt from Copilot chat.

Create a planning/RFC document that captures the plan to detach Options subclasses into explicit OOP while keeping the legacy Faraday::Options for backward compatibility. Add a new Markdown file at docs/options-detach-plan.md with the content below. This document should include: a summary, the main decisions, short focused code examples (OptionsLike, BaseOptions, ProxyOptions, and Utils.deep_merge! snippet), an incremental rollout plan, and a task list that maintainers can “Convert to issues” from the GitHub UI.

File: docs/options-detach-plan.md

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[:key] 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

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Co-authored-by: iMacTia <2527520+iMacTia@users.noreply.github.com>
Copilot AI changed the title [WIP] Add planning document for detaching Options subclasses Add RFC document for Options architecture refactoring plan Oct 11, 2025
Copilot AI requested a review from iMacTia October 11, 2025 11:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants