diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8be100..a9f48998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ 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] + +### 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 + +* **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 + +* **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 + +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. 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 **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..f5d72143 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 diff --git a/lib/savon/client.rb b/lib/savon/client.rb index 89067243..4d31c5e4 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" @@ -20,13 +21,16 @@ 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? raise_initialization_error! end build_wsdl_document + @globals.finalize! end attr_reader :globals, :wsdl @@ -83,6 +87,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/future.rb b/lib/savon/future.rb new file mode 100644 index 00000000..6fdaa12f --- /dev/null +++ b/lib/savon/future.rb @@ -0,0 +1,49 @@ +# 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}. 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. + 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. " \ + "The preview grows with 2.x minor releases. See the '3.0 preview' " \ + "changelog sections and #{DISCUSSION_URL}" + end + end +end 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 8c8e854b..16dcf22b 100644 --- a/lib/savon/options.rb +++ b/lib/savon/options.rb @@ -3,11 +3,20 @@ require "logger" 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. + # + # 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 def initialize(options = {}) @options = {} @@ -16,21 +25,91 @@ def initialize(options = {}) 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) + 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 when creating the client instead." + end + 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 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) + @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 + + # 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. + # Memoized, so reads return stable objects and mutations of a read + # default persist. + # + # @return [Hash{Symbol => Object}] + def defaults + {} + end + + # Values layered between the caller's options and the built-in + # defaults. Subclasses override this hook. + # + # @return [Hash{Symbol => Object}] + 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) @@ -89,9 +168,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 @@ -254,7 +334,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. @@ -265,6 +345,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 @@ -334,15 +426,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 @@ -355,7 +448,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. @@ -457,11 +550,37 @@ 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} 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 + # @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 + end + private + # 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 + # 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: {}, @@ -483,7 +602,8 @@ def defaults no_message_tag: false, unwrap: false, host: nil, - transport: :httpi + transport: :httpi, + future: false ) end end @@ -495,14 +615,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 +715,16 @@ def multipart(multipart) def headers(headers) @options[:headers] = headers end + + private + + # The default value for every local option. + def defaults + @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..2a50f58f --- /dev/null +++ b/spec/savon/future_spec.rb @@ -0,0 +1,193 @@ +# 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 "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 options" do + globals = Savon::GlobalOptions.new(future: true, transport: :httpi) + + expect(globals[:transport]).to eq(:httpi) + end + + 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 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 } + + expect(client.globals[:transport]).to eq(:faraday) + end + + it "keeps 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 "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 "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 + + expect(client.globals[:log]).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/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 diff --git a/spec/savon/options_tracking_spec.rb b/spec/savon/options_tracking_spec.rb new file mode 100644 index 00000000..72ad87c0 --- /dev/null +++ b/spec/savon/options_tracking_spec.rb @@ -0,0 +1,130 @@ +# 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 + + 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 "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" + + 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