From 808fdae356f08fc10c3f84dcfaf46b2254b8e162 Mon Sep 17 00:00:00 2001 From: rubiii Date: Sun, 5 Jul 2026 15:16:45 +0200 Subject: [PATCH 1/5] Add `future: true` as Savon 3.0 preview channel An opt-in global that applies the next major's defaults today. Currently that is Nori's new `:standards` and `:serializable` profiles plus Savon's new `transport: :faraday`. Explicitly set options keep winning. Requires nori ~> 2.9. --- CHANGELOG.md | 18 ++++ lib/savon/block_interface.rb | 10 +- lib/savon/client.rb | 2 + lib/savon/future.rb | 43 ++++++++ lib/savon/options.rb | 102 ++++++++++++++++--- lib/savon/response.rb | 16 +++ savon.gemspec | 2 +- spec/savon/future_spec.rb | 146 ++++++++++++++++++++++++++++ spec/savon/options_tracking_spec.rb | 57 +++++++++++ 9 files changed, 381 insertions(+), 15 deletions(-) create mode 100644 lib/savon/future.rb create mode 100644 spec/savon/future_spec.rb create mode 100644 spec/savon/options_tracking_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8be100..9a6e6334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +* **Add `future: true` global option as Savon 3.0 preview channel** ([discussion #1060](https://github.com/savonrb/savon/discussions/1060)). One opt-in flag that enables the next major version's defaults today. Every option you set explicitly still keeps winning over the future defaults. This grows with 2.x minor releases and changes will be listed here under a new "3.0 preview" section (see below). Release updates are posted in the discussion. With the flag on, the client logs one info-level line at initialization stating this contract. A `log_level` of `:warn` or higher silences it. + +### Changed + +* **Minimum Nori version is now `~> 2.9`** (was `~> 2.7`). Needed for the `standards` and `serializable` parsing profiles that `future: true` enables. Independent of the flag, the Nori 2.8-2.9 series brings one default-on bugfix callers benefit from automatically: whitespace-only CDATA content is preserved as text instead of being dropped. + +### 3.0 preview + +Preview members enabled by `future: true`, new in this release: + +* **Nori `standards: true`** - spec-correct response parsing. Empty tags parse to `""` instead of `nil` (with or without attributes, `xsi:nil="true"` still wins), whitespace-only text under `xml:space="preserve"` is kept, and no types are guessed without a schema: `advanced_typecasting` defaults to off and bare `type=`/`nil=` attributes are ordinary attributes. An explicit `empty_tag_value` or `advanced_typecasting` option keeps winning. +* **Nori `serializable: true`** - plain, directly-serializable response data with no custom value classes. A tag with text and attributes parses to `{"#text" => ..., "@attr" => ...}` instead of a `Nori::StringWithAttributes`, so attributes survive `to_json`. +* **`transport: :faraday`** - the Faraday transport (introduced in 2.17.0) becomes the default under the flag. Requires the `faraday` gem. HTTPI-specific globals such as `proxy`, the timeout and `ssl_*` families, and HTTP auth are rejected at initialization with a per-option migration hint. + ## [2.17.4] - 2026-07-03 **Restore WS-Addressing headers and fix `:wsse_signature` resolution** diff --git a/lib/savon/block_interface.rb b/lib/savon/block_interface.rb index 9153441b..3f978cc1 100644 --- a/lib/savon/block_interface.rb +++ b/lib/savon/block_interface.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true module Savon + # Evaluates an options block against an {Options} target. + # + # A block expecting an argument receives the target directly. A block + # without arguments is instance-evaluated, so bare setter calls reach the + # target through {#method_missing} and unknown methods fall back to the + # scope the block was defined in. class BlockInterface def initialize(target) @target = target @@ -18,7 +24,9 @@ def evaluate(block) private def method_missing(method, *args, &block) - @target.send(method, *args, &block) + result = @target.send(method, *args, &block) + @target.mark_explicit(method) if @target.respond_to?(:mark_explicit) + result rescue NoMethodError @original.send(method, *args, &block) end diff --git a/lib/savon/client.rb b/lib/savon/client.rb index 89067243..e701c9bb 100644 --- a/lib/savon/client.rb +++ b/lib/savon/client.rb @@ -4,6 +4,7 @@ require "savon/transport/httpi" require "savon/transport/faraday" require "savon/options" +require "savon/future" require "savon/block_interface" require "wasabi" @@ -21,6 +22,7 @@ def initialize(globals = {}, &block) set_globals(globals, block) @globals.validate_transport! + Future.announce(@globals[:logger]) if @globals[:future] unless wsdl_or_endpoint_and_namespace_specified? raise_initialization_error! diff --git a/lib/savon/future.rb b/lib/savon/future.rb new file mode 100644 index 00000000..383f69b2 --- /dev/null +++ b/lib/savon/future.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Savon + # The preview channel for the next major version's defaults. + # + # Enabled by the +future: true+ global option, read by + # {Savon::GlobalOptions#future}. The flag is all-or-nothing. Members + # cannot be disabled individually, but every explicitly set option keeps + # winning over a member default. Members grow with 2.x minor releases, + # and each release lists its additions under the "3.0 preview" section + # of the changelog. Version 3.0 makes the members the defaults and the + # flag a no-op. + module Future + # Global options the flag overlays between the built-in defaults and + # the caller's explicit options. + GLOBAL_DEFAULTS = { + transport: :faraday + }.freeze + + # Nori profiles Savon enables for response parsing under the flag. + # {Savon::Response} passes them when building its Nori instance. + NORI_PROFILES = { + standards: true, + serializable: true + }.freeze + + # Where the preview channel is announced and release updates are posted. + DISCUSSION_URL = "https://github.com/savonrb/savon/discussions/1060" + + # Logs the preview contract once at client initialization. + # + # The line is info-level. A +log_level+ of +:warn+ or higher, or a + # custom logger, silences it. + # + # @param logger [Logger] the logger of the client being initialized + # @return [void] + def self.announce(logger) + logger.info "Savon future: true is on. This client previews the Savon 3.0 defaults. " \ + "Preview members grow with 2.x minor releases. See the '3.0 preview' " \ + "changelog sections and #{DISCUSSION_URL}" + end + end +end diff --git a/lib/savon/options.rb b/lib/savon/options.rb index 8c8e854b..9df1b6c5 100644 --- a/lib/savon/options.rb +++ b/lib/savon/options.rb @@ -1,17 +1,29 @@ # frozen_string_literal: true require "logger" +require "set" require "httpi" require "savon/faraday_migration_hint" +require "savon/future" module Savon # Base class for GlobalOptions and LocalOptions. # Stores options in a hash, dispatches setter calls by method name, # raises UnknownOptionError for anything not defined on the subclass. + # + # Owns the layering of built-in defaults and caller-provided options. + # Subclasses supply their defaults through the private {#defaults} hook. + # Every option set by the caller is tracked, so {#explicit?} can tell a + # deliberate choice apart from a default even when both hold the same value. class Options def initialize(options = {}) - @options = {} - assign options + @options = {} + @explicit = Set.new + + # Skip defaults the caller overrides, so setters with side effects + # (such as :log and :logger) run once per option. + assign(defaults.reject { |option, _| options.key?(option) }) + assign(options, explicit: true) end attr_reader :option_type @@ -21,6 +33,7 @@ def [](option) end def []=(option, value) + @explicit << option value = [value].flatten send(option, *value) end @@ -29,10 +42,41 @@ def include?(option) @options.key? option end + # Returns whether the caller provided the option, through the + # constructor, a setter call, or a block. Built-in defaults are not + # explicit. Overlays such as the +future+ defaults never replace an + # explicit option. + # + # @param option [Symbol] the option name + # @return [Boolean] + def explicit?(option) + @explicit.include?(option) + end + + # Records an option as caller-provided. {Savon::BlockInterface} calls + # this after forwarding a setter, because block-set options go through + # the plain setters and would otherwise be indistinguishable from + # defaults. + # + # @api private + # @param option [Symbol] the option name + # @return [void] + def mark_explicit(option) + @explicit << option + end + private - def assign(options) + # The built-in default for every option. Subclasses override this hook. + # + # @return [Hash{Symbol => Object}] + def defaults + {} + end + + def assign(options, explicit: false) options.each do |option, value| + @explicit << option if explicit send(option, value) end end @@ -254,7 +298,7 @@ class GlobalOptions < Options def initialize(options = {}) @option_type = :global - options = defaults.merge(options) + options = options.dup # this option is a shortcut on the logger which needs to be set # before it can be modified to set the option. @@ -457,8 +501,35 @@ def transport(transport) @options[:transport] = transport end + # Opt into the preview of the next major version's defaults. + # + # Accepts a strict Boolean and is global-only. When enabled, the + # {Savon::Future} member defaults apply to every option the caller has + # not explicitly set, and the client logs one info-level line at + # initialization. A +log_level+ of +:warn+ or higher silences the line. + # + # @param future [Boolean] whether to preview the next major's defaults + # @raise [ArgumentError] when the value is not +true+ or +false+ + def future(future) + raise ArgumentError, "future: expects true or false, got: #{future.inspect}" unless [true, false].include?(future) + + @options[:future] = future + apply_future_defaults if future + end + private + # Applies the {Savon::Future} member defaults to every option the + # caller has not explicitly set. Runs from the +future+ setter, so the + # overlay works no matter how the flag is assigned. Member options set + # explicitly before keep their value, member options set afterwards + # overwrite the overlay through their plain setters. + def apply_future_defaults + Future::GLOBAL_DEFAULTS.each do |option, value| + send(option, value) unless explicit?(option) + end + end + # The default value for every global option. def defaults HTTPITransportOptions::TRANSPORT_DEFAULTS.merge( @@ -483,7 +554,8 @@ def defaults no_message_tag: false, unwrap: false, host: nil, - transport: :httpi + transport: :httpi, + future: false ) end end @@ -495,14 +567,7 @@ class LocalOptions < Options def initialize(options = {}) @option_type = :local - - defaults = { - advanced_typecasting: true, - response_parser: :nokogiri, - multipart: false - } - - super defaults.merge(options) + super end # The local SOAP header. Expected to be a Hash or respond to #to_s. @@ -602,5 +667,16 @@ def multipart(multipart) def headers(headers) @options[:headers] = headers end + + private + + # The default value for every local option. + def defaults + { + advanced_typecasting: true, + response_parser: :nokogiri, + multipart: false + } + end end end diff --git a/lib/savon/response.rb b/lib/savon/response.rb index 3909f070..3dee1a8b 100644 --- a/lib/savon/response.rb +++ b/lib/savon/response.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "nori" +require "savon/future" require "savon/soap_fault" require "savon/http_error" @@ -169,7 +170,22 @@ def nori } non_nil_nori_options = nori_options.reject { |_, value| value.nil? } + apply_future_profiles(non_nil_nori_options) if @globals[:future] @nori = Nori.new(non_nil_nori_options) end + + # Adds the Nori profiles from {Savon::Future::NORI_PROFILES} under the + # +future+ flag. Explicitly set Savon options keep winning over the + # profile defaults. An explicit +empty_tag_value+ passes through even + # when nil, and +advanced_typecasting+ only reaches Nori when the + # caller set it, leaving the default to the standards profile. + # + # @param nori_options [Hash] the options built for Nori, mutated in place + # @return [void] + def apply_future_profiles(nori_options) + nori_options.merge!(Future::NORI_PROFILES) + nori_options[:empty_tag_value] = @globals[:empty_tag_value] if @globals.explicit?(:empty_tag_value) + nori_options.delete(:advanced_typecasting) unless @locals.explicit?(:advanced_typecasting) + end end end diff --git a/savon.gemspec b/savon.gemspec index 8311b3f9..b1a8e1fb 100644 --- a/savon.gemspec +++ b/savon.gemspec @@ -23,7 +23,7 @@ Gem::Specification.new do |s| s.add_dependency "httpi", ">= 4", " < 5" s.add_dependency "mail", "~> 2.5" s.add_dependency "nokogiri", ">= 1.8.1" - s.add_dependency "nori", "~> 2.7" + s.add_dependency "nori", "~> 2.9" s.add_dependency "wasabi", ">= 5.1.0", " < 6" s.add_development_dependency 'ostruct', '~> 0.6' diff --git a/spec/savon/future_spec.rb b/spec/savon/future_spec.rb new file mode 100644 index 00000000..cad74d7c --- /dev/null +++ b/spec/savon/future_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "spec_helper" +require "logger" +require "stringio" + +RSpec.describe Savon::Future do + let(:no_wsdl_globals) { { endpoint: "http://example.com", namespace: "http://v1.example.com" } } + + describe "validation" do + it "raises ArgumentError for a non-boolean value" do + expect { Savon::GlobalOptions.new(future: 1) } + .to raise_error(ArgumentError, /future: expects true or false/) + end + + it "defaults to false" do + expect(Savon::GlobalOptions.new[:future]).to be(false) + end + + it "is not available as a local option" do + expect { Savon::LocalOptions.new(future: true) } + .to raise_error(Savon::UnknownOptionError) + end + end + + describe "member defaults" do + it "defaults member options to the future values" do + globals = Savon::GlobalOptions.new(future: true) + + expect(globals[:transport]).to eq(:faraday) + end + + it "keeps explicitly set member options" do + globals = Savon::GlobalOptions.new(future: true, transport: :httpi) + + expect(globals[:transport]).to eq(:httpi) + end + + it "keeps explicitly set member options regardless of hash order" do + globals = Savon::GlobalOptions.new(transport: :httpi, future: true) + + expect(globals[:transport]).to eq(:httpi) + end + + it "applies the member defaults when set after initialization" do + globals = Savon::GlobalOptions.new + globals[:future] = true + + expect(globals[:transport]).to eq(:faraday) + end + + it "applies the member defaults when enabled in a client block" do + client = Savon.client(no_wsdl_globals) { future true } + + expect(client.globals[:transport]).to eq(:faraday) + end + + it "keeps member options set explicitly in a client block" do + client = Savon.client(no_wsdl_globals) { + transport :httpi + future true + } + + expect(client.globals[:transport]).to eq(:httpi) + end + + it "rejects HTTPI-only options just like an explicit faraday transport" do + globals = no_wsdl_globals.merge(future: true, proxy: "http://proxy.example.com") + + expect { Savon.client(globals) } + .to raise_error(Savon::InitializationError, /proxy/) + end + end + + describe "response parsing" do + let(:response_xml) do + '' \ + "" \ + 'abctrue' \ + "" + end + + def parsed_body(globals_hash = {}, locals_hash = {}) + globals = Savon::GlobalOptions.new({ future: true }.merge(globals_hash)) + locals = Savon::LocalOptions.new(locals_hash) + http = Savon::Transport::Response.new(200, {}, response_xml) + + Savon::Response.new(http, globals, locals).body[:authenticate_response] + end + + it "maps empty tags to an empty string (nori standards profile)" do + expect(parsed_body[:empty]).to eq("") + end + + it "returns text with attributes as a plain hash (nori serializable profile)" do + expect(parsed_body[:token]).to eq({ "#text": "abc", "@attr": "x" }) + end + + it "does not guess types without a schema (nori standards profile)" do + expect(parsed_body[:flag]).to eq("true") + end + + it "keeps an explicit advanced_typecasting option over the profile default" do + body = parsed_body({}, advanced_typecasting: true) + + expect(body[:flag]).to be(true) + end + + it "keeps an explicit empty_tag_value option over the profile default" do + body = parsed_body(empty_tag_value: nil) + + expect(body[:empty]).to be_nil + end + + it "leaves parsing untouched without the flag" do + body = parsed_body(future: false) + + expect(body[:empty]).to be_nil + expect(body[:token]).to eq("abc") + expect(body[:flag]).to be(true) + end + end + + describe "the preview announcement" do + def client_output(globals = {}) + io = StringIO.new + Savon.client(no_wsdl_globals.merge(logger: Logger.new(io)).merge(globals)) + io.string + end + + it "logs one info line at client initialization" do + output = client_output(future: true) + + expect(output).to include("future: true") + expect(output).to include("https://github.com/savonrb/savon/discussions/1060") + end + + it "logs nothing when the flag is off" do + expect(client_output).to be_empty + end + + it "is silenced by a log_level above info" do + expect(client_output(future: true, log_level: :warn)).to be_empty + end + end +end diff --git a/spec/savon/options_tracking_spec.rb b/spec/savon/options_tracking_spec.rb new file mode 100644 index 00000000..a6d72f37 --- /dev/null +++ b/spec/savon/options_tracking_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Savon::Options do + describe Savon::GlobalOptions do + it "marks options passed to the constructor as explicit" do + # :strip_namespaces is passed with its default value and tracked + # because we record the caller's intent, not a diff against the + # built-in defaults. + globals = described_class.new(strip_namespaces: true) + + expect(globals.explicit?(:strip_namespaces)).to be(true) + end + + it "does not mark built-in defaults as explicit" do + globals = described_class.new + + expect(globals.explicit?(:strip_namespaces)).to be(false) + end + + it "marks options assigned via []= as explicit" do + globals = described_class.new + globals[:soap_version] = 2 + + expect(globals.explicit?(:soap_version)).to be(true) + end + + it "marks options set through a block as explicit" do + globals = described_class.new + Savon::BlockInterface.new(globals).evaluate(proc { soap_version 2 }) + + expect(globals.explicit?(:soap_version)).to be(true) + end + + it "does not mark other options as explicit when a block sets one" do + globals = described_class.new + Savon::BlockInterface.new(globals).evaluate(proc { soap_version 2 }) + + expect(globals.explicit?(:log)).to be(false) + end + end + + describe Savon::LocalOptions do + it "marks options passed to the constructor as explicit" do + locals = described_class.new(advanced_typecasting: true) + + expect(locals.explicit?(:advanced_typecasting)).to be(true) + end + + it "does not mark built-in defaults as explicit" do + locals = described_class.new + + expect(locals.explicit?(:advanced_typecasting)).to be(false) + end + end +end From 8620ed589eea8ca372fabf8457883f8d472aa0a6 Mon Sep 17 00:00:00 2001 From: rubiii Date: Sun, 5 Jul 2026 16:34:44 +0200 Subject: [PATCH 2/5] Refactor Options to a layered store @options now holds only caller-set values. defaults and the future overlay are separate layers resolved on every read. removes the explicit-option tracking. --- CHANGELOG.md | 4 + lib/savon/block_interface.rb | 4 +- lib/savon/client.rb | 14 +++- lib/savon/options.rb | 120 +++++++++++++++------------- spec/savon/options_tracking_spec.rb | 65 +++++++++++++++ 5 files changed, 149 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6e6334..f0ed7501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +* **Faraday clients no longer touch HTTPI's global logging configuration.** Creating a client stamped `HTTPI.log` and `HTTPI.logger` regardless of the chosen transport. With `transport: :faraday` HTTPI is not involved, so its process-global logging state is now left alone. Clients on the default HTTPI transport still mirror their `log`/`logger` setup to HTTPI, once at client initialization. + ### Added * **Add `future: true` global option as Savon 3.0 preview channel** ([discussion #1060](https://github.com/savonrb/savon/discussions/1060)). One opt-in flag that enables the next major version's defaults today. Every option you set explicitly still keeps winning over the future defaults. This grows with 2.x minor releases and changes will be listed here under a new "3.0 preview" section (see below). Release updates are posted in the discussion. With the flag on, the client logs one info-level line at initialization stating this contract. A `log_level` of `:warn` or higher silences it. diff --git a/lib/savon/block_interface.rb b/lib/savon/block_interface.rb index 3f978cc1..f5d72143 100644 --- a/lib/savon/block_interface.rb +++ b/lib/savon/block_interface.rb @@ -24,9 +24,7 @@ def evaluate(block) private def method_missing(method, *args, &block) - result = @target.send(method, *args, &block) - @target.mark_explicit(method) if @target.respond_to?(:mark_explicit) - result + @target.send(method, *args, &block) rescue NoMethodError @original.send(method, *args, &block) end diff --git a/lib/savon/client.rb b/lib/savon/client.rb index e701c9bb..33ec5329 100644 --- a/lib/savon/client.rb +++ b/lib/savon/client.rb @@ -21,7 +21,8 @@ def initialize(globals = {}, &block) end set_globals(globals, block) - @globals.validate_transport! + @globals.validate! + mirror_logging_to_httpi Future.announce(@globals[:logger]) if @globals[:future] unless wsdl_or_endpoint_and_namespace_specified? @@ -85,6 +86,17 @@ def set_globals(globals, block) @globals = globals end + # Mirrors the client's logging setup to HTTPI's process-global state, + # which HTTPI reads while executing requests. Runs once, after all + # options are assigned. Faraday clients leave HTTPI alone. Goes away in + # Savon 3 together with the HTTPI transport. + def mirror_logging_to_httpi + return if @globals[:transport] == :faraday + + HTTPI.log = @globals[:log] + HTTPI.logger = @globals[:logger] + end + def build_wsdl_document @wsdl = Wasabi::Document.new diff --git a/lib/savon/options.rb b/lib/savon/options.rb index 9df1b6c5..71de9f63 100644 --- a/lib/savon/options.rb +++ b/lib/savon/options.rb @@ -1,82 +1,82 @@ # frozen_string_literal: true require "logger" -require "set" require "httpi" require "savon/faraday_migration_hint" require "savon/future" module Savon # Base class for GlobalOptions and LocalOptions. - # Stores options in a hash, dispatches setter calls by method name, - # raises UnknownOptionError for anything not defined on the subclass. + # Dispatches setter calls by method name and raises UnknownOptionError + # for anything not defined on the subclass. # - # Owns the layering of built-in defaults and caller-provided options. - # Subclasses supply their defaults through the private {#defaults} hook. - # Every option set by the caller is tracked, so {#explicit?} can tell a - # deliberate choice apart from a default even when both hold the same value. + # Stores only what the caller set. Built-in defaults come from the + # private {#defaults} hook and overlays such as the +future+ members from + # {#overlays}. Both stay separate from the caller's options. {#[]} + # resolves the layers on every read, caller over overlay over default, so + # the precedence rules live in one place and never depend on assignment + # order. {#explicit?} tells a deliberate choice apart from a default even + # when both hold the same value. class Options def initialize(options = {}) - @options = {} - @explicit = Set.new - - # Skip defaults the caller overrides, so setters with side effects - # (such as :log and :logger) run once per option. - assign(defaults.reject { |option, _| options.key?(option) }) - assign(options, explicit: true) + @options = {} + assign options end attr_reader :option_type + # Resolves the option: the caller's value if one was set, otherwise an + # overlay value, otherwise the built-in default. def [](option) - @options[option] + return @options[option] if @options.key?(option) + return overlays[option] if overlays.key?(option) + + defaults[option] end def []=(option, value) - @explicit << option value = [value].flatten send(option, *value) end + # Returns whether the option has a value from any layer, set by the + # caller or carrying a built-in default. def include?(option) - @options.key? option + @options.key?(option) || defaults.key?(option) end # Returns whether the caller provided the option, through the - # constructor, a setter call, or a block. Built-in defaults are not - # explicit. Overlays such as the +future+ defaults never replace an - # explicit option. + # constructor, a setter call, or a block. Built-in defaults and + # overlays are not explicit. Overlays such as the +future+ defaults + # never replace an explicit option. # # @param option [Symbol] the option name # @return [Boolean] def explicit?(option) - @explicit.include?(option) - end - - # Records an option as caller-provided. {Savon::BlockInterface} calls - # this after forwarding a setter, because block-set options go through - # the plain setters and would otherwise be indistinguishable from - # defaults. - # - # @api private - # @param option [Symbol] the option name - # @return [void] - def mark_explicit(option) - @explicit << option + @options.key?(option) end private # The built-in default for every option. Subclasses override this hook. + # Memoized, so reads return stable objects and mutations of a read + # default persist. # # @return [Hash{Symbol => Object}] def defaults {} end - def assign(options, explicit: false) + # Values layered between the caller's options and the built-in + # defaults. Subclasses override this hook. + # + # @return [Hash{Symbol => Object}] + def overlays + {} + end + + def assign(options) options.each do |option, value| - @explicit << option if explicit send(option, value) end end @@ -133,9 +133,10 @@ module HTTPITransportOptions # non-default value. The error points to the matching Faraday setup. FARADAY_INCOMPATIBLE_GLOBALS = FaradayMigrationHint::OPTIONS - # Runs after all global options have been assigned, including options set in - # the client block. Reports every HTTPI-only option in one error so callers - # can move their transport setup to Faraday in one pass. + # Runs from {Savon::GlobalOptions#validate!} after all global options have + # been assigned, including options set in the client block. Reports every + # HTTPI-only option in one error so callers can move their transport setup + # to Faraday in one pass. def validate_transport! return unless self[:transport] == :faraday @@ -309,6 +310,18 @@ def initialize(options = {}) log_level(delayed_level) unless delayed_level.nil? end + # Finalizes the options after construction. {Savon::Client} calls this + # once, after the constructor hash and the options block are both + # applied. Validations that need the complete set of options run here + # instead of in individual setters, so their outcome never depends on + # assignment order. + # + # @raise [InitializationError] when the options cannot work together + # @return [void] + def validate! + validate_transport! + end + # Location of the local or remote WSDL document. def wsdl(wsdl_address) @options[:wsdl] = wsdl_address @@ -378,15 +391,16 @@ def raise_errors(raise_errors) @options[:raise_errors] = raise_errors end - # Whether or not to log. + # Whether or not to log. {Savon::Client} mirrors the effective value to + # HTTPI once at initialization when the HTTPI transport is used. def log(log) - HTTPI.log = log @options[:log] = log end # The logger to use. Defaults to a Savon::Logger instance. + # {Savon::Client} mirrors the effective value to HTTPI once at + # initialization when the HTTPI transport is used. def logger(logger) - HTTPI.logger = logger @options[:logger] = logger end @@ -399,7 +413,7 @@ def log_level(level) "Expected one of: #{levels.keys.inspect}" end - @options[:logger].level = levels[level] + self[:logger].level = levels[level] end # Whether to log headers. @@ -514,25 +528,23 @@ def future(future) raise ArgumentError, "future: expects true or false, got: #{future.inspect}" unless [true, false].include?(future) @options[:future] = future - apply_future_defaults if future end private - # Applies the {Savon::Future} member defaults to every option the - # caller has not explicitly set. Runs from the +future+ setter, so the - # overlay works no matter how the flag is assigned. Member options set - # explicitly before keep their value, member options set afterwards - # overwrite the overlay through their plain setters. - def apply_future_defaults - Future::GLOBAL_DEFAULTS.each do |option, value| - send(option, value) unless explicit?(option) - end + # Layers the {Savon::Future} member defaults between the caller's + # options and the built-in defaults while the +future+ flag is on. + # Resolution happens in {Savon::Options#[]} on every read, so the + # overlay works no matter how or when the flag is assigned. Reads the + # flag from the caller's options directly because resolving it through + # {Savon::Options#[]} would recurse into this hook. + def overlays + @options[:future] ? Future::GLOBAL_DEFAULTS : {} end # The default value for every global option. def defaults - HTTPITransportOptions::TRANSPORT_DEFAULTS.merge( + @defaults ||= HTTPITransportOptions::TRANSPORT_DEFAULTS.merge( encoding: "UTF-8", soap_version: 1, namespaces: {}, @@ -672,7 +684,7 @@ def headers(headers) # The default value for every local option. def defaults - { + @defaults ||= { advanced_typecasting: true, response_parser: :nokogiri, multipart: false diff --git a/spec/savon/options_tracking_spec.rb b/spec/savon/options_tracking_spec.rb index a6d72f37..9f187c92 100644 --- a/spec/savon/options_tracking_spec.rb +++ b/spec/savon/options_tracking_spec.rb @@ -54,4 +54,69 @@ expect(locals.explicit?(:advanced_typecasting)).to be(false) end end + + describe "storage and resolution" do + it "returns the built-in default for options the caller did not set" do + expect(Savon::GlobalOptions.new[:soap_version]).to eq(1) + end + + it "returns the caller's value for options the caller set" do + expect(Savon::GlobalOptions.new(soap_version: 2)[:soap_version]).to eq(2) + end + + it "returns the caller's value even when it is nil" do + globals = Savon::GlobalOptions.new(convert_response_tags_to: nil) + + expect(globals[:convert_response_tags_to]).to be_nil + end + + it "is explicit for options the caller set to nil" do + globals = Savon::GlobalOptions.new(empty_tag_value: nil) + + expect(globals.explicit?(:empty_tag_value)).to be(true) + end + + it "includes defaulted options" do + expect(Savon::GlobalOptions.new.include?(:log)).to be(true) + expect(Savon::LocalOptions.new.include?(:advanced_typecasting)).to be(true) + end + + it "does not include unset options without a default" do + expect(Savon::GlobalOptions.new.include?(:wsdl)).to be(false) + end + + it "returns the same default object on every read" do + globals = Savon::GlobalOptions.new + + expect(globals[:namespaces]).to equal(globals[:namespaces]) + end + + it "persists mutations of a read default" do + globals = Savon::GlobalOptions.new + globals[:namespaces]["xmlns:ins0"] = "http://example.com" + + expect(globals[:namespaces]).to eq("xmlns:ins0" => "http://example.com") + end + + it "mirrors the :log default to HTTPI at client initialization" do + HTTPI.log = true + Savon.client(endpoint: "http://example.com", namespace: "http://v1.example.com") + + expect(HTTPI.log?).to be(false) + ensure + HTTPI.log = false + end + + it "leaves HTTPI's logging state alone under transport: :faraday" do + # HTTPI is not involved in a Faraday client, so its process-global + # logging configuration stays whatever it was. + HTTPI.log = true + Savon.client(endpoint: "http://example.com", namespace: "http://v1.example.com", + transport: :faraday) + + expect(HTTPI.log?).to be(true) + ensure + HTTPI.log = false + end + end end From ca7cea798f7da42b2194c0e39a18b8c12c4f5554 Mon Sep 17 00:00:00 2001 From: rubiii Date: Sun, 5 Jul 2026 17:10:31 +0200 Subject: [PATCH 3/5] Freeze client options under future: true client.globals freezes once the client is created. post client-creation writes raise a FrozenError. Mutation after init skips validation, WSDL setup and causes other problems like not being thread-safe. --- CHANGELOG.md | 3 ++- lib/savon/client.rb | 1 + lib/savon/future.rb | 20 +++++++++++------- lib/savon/options.rb | 34 +++++++++++++++++++++++------- spec/savon/future_spec.rb | 44 ++++++++++++++++++++++++++++++++------- 5 files changed, 79 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ed7501..6b0e1145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 3.0 preview -Preview members enabled by `future: true`, new in this release: +New in the `future: true` preview with this release: * **Nori `standards: true`** - spec-correct response parsing. Empty tags parse to `""` instead of `nil` (with or without attributes, `xsi:nil="true"` still wins), whitespace-only text under `xml:space="preserve"` is kept, and no types are guessed without a schema: `advanced_typecasting` defaults to off and bare `type=`/`nil=` attributes are ordinary attributes. An explicit `empty_tag_value` or `advanced_typecasting` option keeps winning. * **Nori `serializable: true`** - plain, directly-serializable response data with no custom value classes. A tag with text and attributes parses to `{"#text" => ..., "@attr" => ...}` instead of a `Nori::StringWithAttributes`, so attributes survive `to_json`. * **`transport: :faraday`** - the Faraday transport (introduced in 2.17.0) becomes the default under the flag. Requires the `faraday` gem. HTTPI-specific globals such as `proxy`, the timeout and `ssl_*` families, and HTTP auth are rejected at initialization with a per-option migration hint. +* **Frozen client options** - `client.globals` is frozen once the client is created. Setting a global afterwards raises a `FrozenError` explaining the contract. Mutating options after creation skipped initialization-time validation and WSDL setup and was never thread-safe. Savon 3 makes every client immutable. This includes `Savon::Model.global`. Options need to be passed to `client(...)` instead. ## [2.17.4] - 2026-07-03 diff --git a/lib/savon/client.rb b/lib/savon/client.rb index 33ec5329..86d30186 100644 --- a/lib/savon/client.rb +++ b/lib/savon/client.rb @@ -30,6 +30,7 @@ def initialize(globals = {}, &block) end build_wsdl_document + @globals.freeze if @globals[:future] end attr_reader :globals, :wsdl diff --git a/lib/savon/future.rb b/lib/savon/future.rb index 383f69b2..6fdaa12f 100644 --- a/lib/savon/future.rb +++ b/lib/savon/future.rb @@ -4,12 +4,18 @@ module Savon # The preview channel for the next major version's defaults. # # Enabled by the +future: true+ global option, read by - # {Savon::GlobalOptions#future}. The flag is all-or-nothing. Members - # cannot be disabled individually, but every explicitly set option keeps - # winning over a member default. Members grow with 2.x minor releases, - # and each release lists its additions under the "3.0 preview" section - # of the changelog. Version 3.0 makes the members the defaults and the - # flag a no-op. + # {Savon::GlobalOptions#future}. Everything the preview changes arrives + # as a default. An option you set explicitly wins over a previewed + # default the same way it wins over a built-in one. There is no finer + # control than that. Previewed behavior changes cannot be switched off + # individually. The preview grows with 2.x minor releases, and each + # release lists its additions under the "3.0 preview" section of the + # changelog. Version 3.0 makes the previewed behavior the default and + # the flag a no-op. + # + # The preview covers option defaults ({GLOBAL_DEFAULTS} and + # {NORI_PROFILES}) as well as behavior changes such as {Savon::Client} + # freezing its options once the client is created. module Future # Global options the flag overlays between the built-in defaults and # the caller's explicit options. @@ -36,7 +42,7 @@ module Future # @return [void] def self.announce(logger) logger.info "Savon future: true is on. This client previews the Savon 3.0 defaults. " \ - "Preview members grow with 2.x minor releases. See the '3.0 preview' " \ + "The preview grows with 2.x minor releases. See the '3.0 preview' " \ "changelog sections and #{DISCUSSION_URL}" end end diff --git a/lib/savon/options.rb b/lib/savon/options.rb index 71de9f63..8ec7887d 100644 --- a/lib/savon/options.rb +++ b/lib/savon/options.rb @@ -10,11 +10,11 @@ module Savon # Dispatches setter calls by method name and raises UnknownOptionError # for anything not defined on the subclass. # - # Stores only what the caller set. Built-in defaults come from the - # private {#defaults} hook and overlays such as the +future+ members from - # {#overlays}. Both stay separate from the caller's options. {#[]} - # resolves the layers on every read, caller over overlay over default, so - # the precedence rules live in one place and never depend on assignment + # Stores only what the caller set. Built-in defaults come from the private + # {#defaults} hook and overlays such as the +future+ preview defaults from + # {#overlays}. Both stay separate from the caller's options. + # {#[]} resolves the layers on every read, caller over overlay over default, + # so the precedence rules live in one place and never depend on assignment # order. {#explicit?} tells a deliberate choice apart from a default even # when both hold the same value. class Options @@ -35,6 +35,13 @@ def [](option) end def []=(option, value) + if frozen? + raise FrozenError, "Can't set the #{option_type} option #{option.inspect}. " \ + "Options are frozen once the client is created (future: true). " \ + "Savon 3 freezes the options of every client. " \ + "Pass all options to Savon.client(...) instead." + end + value = [value].flatten send(option, *value) end @@ -56,6 +63,17 @@ def explicit?(option) @options.key?(option) end + # Freezes the options. Reads keep resolving through all layers, and + # setting an option through {#[]=} raises FrozenError afterwards. + # {Savon::Client} freezes its globals once the client is created under + # the +future+ flag. Savon 3 does it for every client. + # + # @return [self] + def freeze + defaults # memoize, so reads never write to the frozen object + super + end + private # The built-in default for every option. Subclasses override this hook. @@ -518,8 +536,8 @@ def transport(transport) # Opt into the preview of the next major version's defaults. # # Accepts a strict Boolean and is global-only. When enabled, the - # {Savon::Future} member defaults apply to every option the caller has - # not explicitly set, and the client logs one info-level line at + # {Savon::Future} previewed defaults apply to every option the caller + # has not explicitly set, and the client logs one info-level line at # initialization. A +log_level+ of +:warn+ or higher silences the line. # # @param future [Boolean] whether to preview the next major's defaults @@ -532,7 +550,7 @@ def future(future) private - # Layers the {Savon::Future} member defaults between the caller's + # Layers the {Savon::Future} previewed defaults between the caller's # options and the built-in defaults while the +future+ flag is on. # Resolution happens in {Savon::Options#[]} on every read, so the # overlay works no matter how or when the flag is assigned. Reads the diff --git a/spec/savon/future_spec.rb b/spec/savon/future_spec.rb index cad74d7c..f48f2733 100644 --- a/spec/savon/future_spec.rb +++ b/spec/savon/future_spec.rb @@ -23,39 +23,39 @@ end end - describe "member defaults" do - it "defaults member options to the future values" do + describe "previewed defaults" do + it "applies the previewed defaults" do globals = Savon::GlobalOptions.new(future: true) expect(globals[:transport]).to eq(:faraday) end - it "keeps explicitly set member options" do + it "keeps explicitly set options" do globals = Savon::GlobalOptions.new(future: true, transport: :httpi) expect(globals[:transport]).to eq(:httpi) end - it "keeps explicitly set member options regardless of hash order" do + it "keeps explicitly set options regardless of hash order" do globals = Savon::GlobalOptions.new(transport: :httpi, future: true) expect(globals[:transport]).to eq(:httpi) end - it "applies the member defaults when set after initialization" do + it "applies the previewed defaults when set after initialization" do globals = Savon::GlobalOptions.new globals[:future] = true expect(globals[:transport]).to eq(:faraday) end - it "applies the member defaults when enabled in a client block" do + it "applies the previewed defaults when enabled in a client block" do client = Savon.client(no_wsdl_globals) { future true } expect(client.globals[:transport]).to eq(:faraday) end - it "keeps member options set explicitly in a client block" do + it "keeps options set explicitly in a client block" do client = Savon.client(no_wsdl_globals) { transport :httpi future true @@ -121,6 +121,36 @@ def parsed_body(globals_hash = {}, locals_hash = {}) end end + describe "frozen options" do + it "freezes the globals once the client is created" do + client = Savon.client(no_wsdl_globals.merge(future: true)) + + expect(client.globals).to be_frozen + end + + it "raises a helpful error when setting a global after client creation" do + client = Savon.client(no_wsdl_globals.merge(future: true)) + + expect { client.globals[:log] = true } + .to raise_error(FrozenError, /frozen once the client is created/) + end + + it "keeps resolving reads on the frozen globals" do + client = Savon.client(no_wsdl_globals.merge(future: true)) + + # :soap_version comes from the defaults layer - reading it must not + # write any memoization state to the frozen object. + expect(client.globals[:soap_version]).to eq(1) + end + + it "keeps the globals mutable without the flag" do + client = Savon.client(no_wsdl_globals) + client.globals[:log] = true + + expect(client.globals[:log]).to be(true) + end + end + describe "the preview announcement" do def client_output(globals = {}) io = StringIO.new From d841da651757a5d20e3ca71e31b57bdbfb390718 Mon Sep 17 00:00:00 2001 From: rubiii Date: Sun, 5 Jul 2026 17:37:18 +0200 Subject: [PATCH 4/5] Integrate Savon::Model with future: true With the flag in its `client(...)` configuration, Model now records options, later `.global` calls included, and creates the client once on first use. Without the flag, Model behavior is unchanged. --- CHANGELOG.md | 4 +-- lib/savon/client.rb | 2 +- lib/savon/model.rb | 23 ++++++++++-- lib/savon/options.rb | 14 +++++++- spec/savon/future_spec.rb | 8 +++++ spec/savon/model_spec.rb | 73 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0e1145..def0c9ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* **Add `future: true` global option as Savon 3.0 preview channel** ([discussion #1060](https://github.com/savonrb/savon/discussions/1060)). One opt-in flag that enables the next major version's defaults today. Every option you set explicitly still keeps winning over the future defaults. This grows with 2.x minor releases and changes will be listed here under a new "3.0 preview" section (see below). Release updates are posted in the discussion. With the flag on, the client logs one info-level line at initialization stating this contract. A `log_level` of `:warn` or higher silences it. +* **Add `future: true` global option as Savon 3.0 preview channel** ([discussion #1060](https://github.com/savonrb/savon/discussions/1060)). One opt-in flag that enables the next major version's defaults today. Every option you set explicitly still keeps winning over the future defaults. This grows with 2.x minor releases and changes will be listed here under a new "3.0 preview" section (see below). Release updates are posted in the discussion. With the flag on, the client logs one info-level line at initialization stating this contract. A `log_level` of `:warn` or higher silences it. The flag can only be set when creating a client. ### Changed @@ -26,7 +26,7 @@ New in the `future: true` preview with this release: * **Nori `standards: true`** - spec-correct response parsing. Empty tags parse to `""` instead of `nil` (with or without attributes, `xsi:nil="true"` still wins), whitespace-only text under `xml:space="preserve"` is kept, and no types are guessed without a schema: `advanced_typecasting` defaults to off and bare `type=`/`nil=` attributes are ordinary attributes. An explicit `empty_tag_value` or `advanced_typecasting` option keeps winning. * **Nori `serializable: true`** - plain, directly-serializable response data with no custom value classes. A tag with text and attributes parses to `{"#text" => ..., "@attr" => ...}` instead of a `Nori::StringWithAttributes`, so attributes survive `to_json`. * **`transport: :faraday`** - the Faraday transport (introduced in 2.17.0) becomes the default under the flag. Requires the `faraday` gem. HTTPI-specific globals such as `proxy`, the timeout and `ssl_*` families, and HTTP auth are rejected at initialization with a per-option migration hint. -* **Frozen client options** - `client.globals` is frozen once the client is created. Setting a global afterwards raises a `FrozenError` explaining the contract. Mutating options after creation skipped initialization-time validation and WSDL setup and was never thread-safe. Savon 3 makes every client immutable. This includes `Savon::Model.global`. Options need to be passed to `client(...)` instead. +* **Frozen client options** - `client.globals` is frozen once the client is created. Setting a global afterwards raises a `FrozenError` explaining the contract. Mutating options after creation skipped initialization-time validation and WSDL setup and was never thread-safe. Savon 3 makes every client immutable. With `Savon::Model`, enabling the future flag in its `client(...)` configuration changes the model to record options, later `global` calls included, and create the client once on first use. Configuring the model after first use raises. ## [2.17.4] - 2026-07-03 diff --git a/lib/savon/client.rb b/lib/savon/client.rb index 86d30186..4d31c5e4 100644 --- a/lib/savon/client.rb +++ b/lib/savon/client.rb @@ -30,7 +30,7 @@ def initialize(globals = {}, &block) end build_wsdl_document - @globals.freeze if @globals[:future] + @globals.finalize! end attr_reader :globals, :wsdl diff --git a/lib/savon/model.rb b/lib/savon/model.rb index a8c27055..a8935c61 100644 --- a/lib/savon/model.rb +++ b/lib/savon/model.rb @@ -52,14 +52,33 @@ def operation_method_name(operation) # Class methods. def class_operation_module @class_operation_module ||= Module.new do + # Configures and returns the model's Savon::Client. The first call + # with options is the configuration, later options are ignored. + # With +future: true+ in the configuration, creation is deferred + # until first use, so options recorded by +global+ become part of + # one client. The configuring call then returns nil. def client(globals = {}) - @client ||= Savon::Client.new(globals) + if globals.any? + @client_globals ||= globals.dup + return if @client_globals[:future] + end + + @client ||= Savon::Client.new(@client_globals || {}) rescue InitializationError raise_initialization_error! end + # Sets a single global option. With +future: true+ this records + # into the configuration of the not-yet-created client. Without + # the flag it mutates the live client, as it always has. def global(option, *value) - client.globals[option] = value + if @client.nil? && @client_globals && @client_globals[:future] + # Constructor setters take one argument, so single values are + # unwrapped the way []= would flatten them. + @client_globals[option] = value.size == 1 ? value.first : value + else + client.globals[option] = value + end end def raise_initialization_error! diff --git a/lib/savon/options.rb b/lib/savon/options.rb index 8ec7887d..a620be65 100644 --- a/lib/savon/options.rb +++ b/lib/savon/options.rb @@ -39,7 +39,7 @@ def []=(option, value) raise FrozenError, "Can't set the #{option_type} option #{option.inspect}. " \ "Options are frozen once the client is created (future: true). " \ "Savon 3 freezes the options of every client. " \ - "Pass all options to Savon.client(...) instead." + "Pass all options when creating the client instead." end value = [value].flatten @@ -74,6 +74,17 @@ def freeze super end + # Marks the end of the configuration phase. {Savon::Client} calls this + # once the client is fully created. Init-only options such as +future+ + # reject writes afterwards, and with the +future+ flag on the options + # freeze entirely. Options never handed to a client stay unfinalized. + # + # @return [void] + def finalize! + @finalized = true + freeze if self[:future] + end + private # The built-in default for every option. Subclasses override this hook. @@ -543,6 +554,7 @@ def transport(transport) # @param future [Boolean] whether to preview the next major's defaults # @raise [ArgumentError] when the value is not +true+ or +false+ def future(future) + raise ArgumentError, "future can only be set when the client is created" if @finalized raise ArgumentError, "future: expects true or false, got: #{future.inspect}" unless [true, false].include?(future) @options[:future] = future diff --git a/spec/savon/future_spec.rb b/spec/savon/future_spec.rb index f48f2733..5c3edcad 100644 --- a/spec/savon/future_spec.rb +++ b/spec/savon/future_spec.rb @@ -43,12 +43,20 @@ end it "applies the previewed defaults when set after initialization" do + # A bare options object is never finalized globals = Savon::GlobalOptions.new globals[:future] = true expect(globals[:transport]).to eq(:faraday) end + it "cannot be enabled after the client is created" do + client = Savon.client(no_wsdl_globals) + + expect { client.globals[:future] = true } + .to raise_error(ArgumentError, /future can only be set when the client is created/) + end + it "applies the previewed defaults when enabled in a client block" do client = Savon.client(no_wsdl_globals) { future true } diff --git a/spec/savon/model_spec.rb b/spec/savon/model_spec.rb index 490f1f10..b8bb7a99 100644 --- a/spec/savon/model_spec.rb +++ b/spec/savon/model_spec.rb @@ -2,6 +2,8 @@ require "spec_helper" require "integration/support/server" +require "logger" +require "stringio" RSpec.describe Savon::Model do describe ".client" do @@ -39,6 +41,77 @@ expect(model.client.globals[:open_timeout]).to eq(71) expect(model.client.globals[:wsse_auth]).to eq(["luke", "secret", :digest]) end + + it "keeps mutating the memoized client without the future flag" do + model = Class.new do + extend Savon::Model + client wsdl: Fixture.wsdl(:authentication) + end + + first = model.client + model.global(:soap_version, 2) + + expect(model.client).to equal(first) + expect(model.client.globals[:soap_version]).to eq(2) + end + end + + describe ".global with future: true" do + def quiet_logger(io) + Logger.new(io) + end + + it "records options into the one client created on first use" do + io = StringIO.new + logger = quiet_logger(io) + + model = Class.new do + extend Savon::Model + + client wsdl: Fixture.wsdl(:authentication), future: true, logger: logger + + global :soap_version, 2 + global :wsse_auth, "luke", "secret", :digest + end + + expect(model.client.globals[:soap_version]).to eq(2) + expect(model.client.globals[:wsse_auth]).to eq(["luke", "secret", :digest]) + expect(model.client.globals[:future]).to be(true) + expect(model.client.globals).to be_frozen + end + + it "creates the client once and announces the preview once" do + io = StringIO.new + logger = quiet_logger(io) + + model = Class.new do + extend Savon::Model + + client wsdl: Fixture.wsdl(:authentication), future: true, logger: logger + + global :soap_version, 2 + end + + expect(io.string).to be_empty + + model.client + + expect(io.string.scan("future: true").size).to eq(1) + end + + it "rejects configuration after first use" do + io = StringIO.new + logger = quiet_logger(io) + + model = Class.new do + extend Savon::Model + client wsdl: Fixture.wsdl(:authentication), future: true, logger: logger + end + model.client + + expect { model.global(:soap_version, 2) } + .to raise_error(FrozenError, /frozen once the client is created/) + end end describe ".operations" do From 1716b3cb588e25faa6b69f5de87830fb23a7de60 Mon Sep 17 00:00:00 2001 From: rubiii Date: Sun, 5 Jul 2026 18:12:06 +0200 Subject: [PATCH 5/5] Copy storage when duplicating an options object client.globals.dup shared the internal storage with the original, so writing to the copy silently reconfigured the client. --- CHANGELOG.md | 1 + lib/savon/options.rb | 6 ++++++ spec/savon/future_spec.rb | 9 +++++++++ spec/savon/options_tracking_spec.rb | 8 ++++++++ 4 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index def0c9ed..a9f48998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **Writing to a duplicated options object no longer changes the original.** `client.globals.dup` shared its internal storage with the client, so writes to the copy silently reconfigured it. * **Faraday clients no longer touch HTTPI's global logging configuration.** Creating a client stamped `HTTPI.log` and `HTTPI.logger` regardless of the chosen transport. With `transport: :faraday` HTTPI is not involved, so its process-global logging state is now left alone. Clients on the default HTTPI transport still mirror their `log`/`logger` setup to HTTPI, once at client initialization. ### Added diff --git a/lib/savon/options.rb b/lib/savon/options.rb index a620be65..16dcf22b 100644 --- a/lib/savon/options.rb +++ b/lib/savon/options.rb @@ -104,6 +104,12 @@ def overlays {} end + # A copy gets its own storage, so writes to it never reach the original. + def initialize_copy(source) + super + @options = @options.dup + end + def assign(options) options.each do |option, value| send(option, value) diff --git a/spec/savon/future_spec.rb b/spec/savon/future_spec.rb index 5c3edcad..2a50f58f 100644 --- a/spec/savon/future_spec.rb +++ b/spec/savon/future_spec.rb @@ -151,6 +151,15 @@ def parsed_body(globals_hash = {}, locals_hash = {}) expect(client.globals[:soap_version]).to eq(1) end + it "cannot be changed through a dup of the frozen globals" do + client = Savon.client(no_wsdl_globals.merge(future: true)) + + copy = client.globals.dup + copy[:log] = true + + expect(client.globals[:log]).to be(false) + end + it "keeps the globals mutable without the flag" do client = Savon.client(no_wsdl_globals) client.globals[:log] = true diff --git a/spec/savon/options_tracking_spec.rb b/spec/savon/options_tracking_spec.rb index 9f187c92..72ad87c0 100644 --- a/spec/savon/options_tracking_spec.rb +++ b/spec/savon/options_tracking_spec.rb @@ -91,6 +91,14 @@ expect(globals[:namespaces]).to equal(globals[:namespaces]) end + it "gives a dup its own storage" do + globals = Savon::GlobalOptions.new + copy = globals.dup + copy[:soap_version] = 2 + + expect(globals[:soap_version]).to eq(1) + end + it "persists mutations of a read default" do globals = Savon::GlobalOptions.new globals[:namespaces]["xmlns:ins0"] = "http://example.com"