savon 2.17.1 → 2.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00ae14802f108cb0df2757b85888a35df4b6b0e462ae0d01c1eedc9b4f9c123c
4
- data.tar.gz: cee1d2d13ed6e869611817271c43352c470c4c7fb79e72dc7e6e7a4d9ac37191
3
+ metadata.gz: ffcf3a770d4dd548ec193c9f6d8b8723eef3a661273dc50e8bb5a06ba99f0f13
4
+ data.tar.gz: d03d1de9d1bc5b1af722b56dc71e99acc1a46f951f45d377b9eea2974016f2ed
5
5
  SHA512:
6
- metadata.gz: 8b754234ac51e2213f92254a88345743cc54e09026e220d85931b8bfe0ba78f5a417f81e561047b37410f52713f3583120c6d3095ab68a2b9705427f03822d07
7
- data.tar.gz: 49a3260e064073d9370b80b376db5f9c75bab5e38dbdf7db2ffc8533dc2a58652bb0699830468f1e59319a98fd7210503a6c999b386e3ec6c9f4d23ac7f813e1
6
+ metadata.gz: 5a5e490cbf30a0e2216439dc9ceb51493c5b5fec107ce3ff4219eb8c5ded1c9e3b612b6bb8b44c4163d12a97c73415f441f33542b35e22bd7b1908f8cb123fa2
7
+ data.tar.gz: c6d5c85d39f406f75e5e0b005b250e5a97da5d60e42ca44fe827d72f97b2fd967a054684be73e8134c19506e8da6e8dcc7222769fa017d2b7c02cf2336e6dc12
data/CHANGELOG.md CHANGED
@@ -1,38 +1,91 @@
1
1
  # Savon changelog
2
2
 
3
- ## 2.17.1 (2026-05-21)
3
+ All notable changes to this project will be documented in this file.
4
4
 
5
- * Fix: [#1008](https://github.com/savonrb/savon/pull/1008) - The HTTPI and Faraday transports no longer set an explicit `Content-Length` request header. The underlying HTTP library already computes it from the body; sending it as well produced a duplicate header on adapters that do not deduplicate (e.g. httpclient), which some servers reject.
6
- * Fix: Requests using `attachments` were sent with a plain `text/xml` Content-Type instead of `multipart/related`. The 2.17.0 transport refactor assembled the request headers before the multipart body was built, leaving `Builder#multipart` empty at header time, so servers received a multipart body labelled as plain XML. 2.16.x and earlier are unaffected.
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## 2.17.0 (2026-05-19)
8
+ ## [2.17.2] - 2026-06-10
9
+
10
+ **Fix CVE-2026-53510 and restore 2.17.0 cookie regressions**
11
+
12
+ ### Fixed
13
+
14
+ * **Fix CVE-2026-53510** `Savon::Model` generated SOAP operation methods by interpolating operation names into Ruby source passed to `module_eval`. An attacker who can control the operation names of a WSDL, can inject Ruby code that executes in the application process. This affects only the `.all_operations` class method provided by `Savon::Model` to automatically register all operations provided by the WSDL. Configuring `Savon::Model` with trusted operation names via `.operations` is safe. Thanks to @connorshea for securely disclosing this, providing a proof and a great report.
15
+ * **`:cookies` request option works again.** The 2.17.0 transport refactor reimplemented cookie handling on top of `Array#map`, which broke callers passing an object that responds to `#cookies` and lost cookie-name de-duplication via `HTTPI::CookieStore`. The HTTPI transport delegates to `HTTPI::Request#set_cookies` again, restoring both shapes.
16
+ * **`response.http.cookies` works again.** 2.17.0's `Savon::Transport::Response` only exposed `code`, `headers`, and `body`. The HTTPI transport now returns `Array<HTTPI::Cookie>` (matching 2.12.1). The Faraday transport returns `Hash<String, String>` so Faraday callers do not need HTTPI types.
17
+ * **`:attachments` now works with a user-supplied `:xml` envelope** ([#761](https://github.com/savonrb/savon/pull/761)). Multipart support shipped in 2.13.0 but only wrapped envelopes Savon built itself. When a caller passed their own `:xml`, attachments were silently dropped.
18
+
19
+ ### Added
20
+
21
+ * **Faraday `:cookies` option accepts a `String` or `Hash`.** Strings are used verbatim, Hashes are formatted as `"name=value; name=value"`. Round-trippable with the Faraday response shape.
22
+ * **Three Nori response-parsing options exposed as Savon globals:** `:empty_tag_value` (default `nil`), `:convert_dashes_to_underscores` (default `true`), and `:scrub_xml` (default `true`). Defaults match Nori's own for backwards compatibility.
23
+
24
+ ### Changed
25
+
26
+ * **Minimum Nori version is now `~> 2.7`** (was `~> 2.4`). Needed for the new parsing options (`:empty_tag_value` arrived in Nori 2.6.0, `:scrub_xml` in 2.7.0). The 2.5–2.7 series also brings fixes callers benefit from automatically: invalid byte sequences parse instead of raising, REXML no longer turns `&lt;` inside CDATA into `<`, `xs:date`/`xs:time`/`xs:dateTime` typecasting was corrected, and Nori stopped monkey-patching `String` and `Object`.
27
+ * **Faraday migration hints are now value-aware and verified.** Each hint prints the caller's actual option value and spells out the full gem/require/setup where needed. Fixed several incorrect examples and added tests to verify every hint.
28
+
29
+ ### Deprecated
30
+
31
+ * Deprecated the global and local `:multipart` options. They have been no-ops since v2.13.0. Specifically since commit 4e7ae5e. Savon detects multipart responses by checking the `Content-Type` header.
32
+
33
+ ## [2.17.1] - 2026-05-21
34
+
35
+ ### Fixed
36
+
37
+ * [#1008](https://github.com/savonrb/savon/pull/1008) - The HTTPI and Faraday transports no longer set an explicit `Content-Length` request header. The underlying HTTP library already computes it from the body. Sending it as well produced a duplicate header on adapters that do not deduplicate (e.g. httpclient), which some servers reject.
38
+ * Requests using `attachments` were sent with a plain `text/xml` Content-Type instead of `multipart/related`. The 2.17.0 transport refactor assembled the request headers before the multipart body was built, leaving `Builder#multipart` empty at header time, so servers received a multipart body labelled as plain XML. 2.16.x and earlier are unaffected.
39
+
40
+ ## [2.17.0] - 2026-05-19
9
41
 
10
42
  **Add opt-in Faraday transport**
11
43
 
12
44
  Callers who set `transport: :faraday` get a memoized `Faraday::Connection` via `client.faraday` and full control over middleware, SSL, auth, and timeouts. Callers who do not set this option see no behavior change. HTTPI remains the default for 2.x.
13
45
 
14
- * Add: `transport: :faraday` global option. Defaults to `:httpi` (#992).
15
- * Add: `client.faraday` returns a memoized `Faraday::Connection` for configuring middleware, SSL, auth, and timeouts when using the Faraday transport.
16
- * Add: `Savon.client` raises if `transport: :faraday` is set but the faraday gem is not installed, or if any httpi-specific global option (`proxy`, timeouts, `ssl`, auth, `adapter`) is set alongside it. All conflicts are reported with their Faraday equivalents.
17
- * Change: Observers must return `Savon::Transport::Response` (or `nil`) instead of `HTTPI::Response`. Returning `HTTPI::Response` still works but emits a deprecation warning.
18
- * Unblocks:
19
- * redirect following for WSDL fetches via `faraday-follow-redirects` middleware (#1033, savonrb/wasabi#18)
20
- * digest authentication via `faraday-digestauth` middleware (#1021, savonrb/httpi#250)
21
- * proxy authentication with special characters in passwords (#941)
22
- * and setting an `Accept` header for WSDL requests from Rails apps (savonrb/wasabi#115)
46
+ ### Added
23
47
 
24
- ## 2.16.0 (2026-05-18)
48
+ * `transport: :faraday` global option. Defaults to `:httpi` (#992).
49
+ * `client.faraday` returns a memoized `Faraday::Connection` for configuring middleware, SSL, auth, and timeouts when using the Faraday transport.
50
+ * `Savon.client` raises if `transport: :faraday` is set but the faraday gem is not installed, or if any httpi-specific global option (`proxy`, timeouts, `ssl`, auth, `adapter`) is set alongside it. All conflicts are reported with their Faraday equivalents.
51
+
52
+ ### Changed
53
+
54
+ * Observers must return `Savon::Transport::Response` (or `nil`) instead of `HTTPI::Response`.
55
+
56
+ ### Deprecated
57
+
58
+ * The HTTPI transport (currently the default) and all HTTPI-specific global options enumerated in `Savon::FaradayMigrationHint::OPTIONS` (`proxy`, `open_timeout`, `read_timeout`, `write_timeout`, the `ssl_*` family, `basic_auth`, `digest_auth`, `ntlm`, `follow_redirects`, `adapter`) will be removed in 3.0. Migrate to `transport: :faraday`. `FaradayMigrationHint` shows the Faraday equivalent for each option.
59
+ * Returning `HTTPI::Response` from observers emits a deprecation warning and will be removed in 3.0. Return `Savon::Transport::Response` instead.
60
+
61
+ The Faraday transport unblocks:
62
+
63
+ * redirect following for WSDL fetches via `faraday-follow-redirects` middleware (#1033, savonrb/wasabi#18)
64
+ * digest authentication via `faraday-digestauth` middleware (#1021, savonrb/httpi#250)
65
+ * proxy authentication with special characters in passwords (#941)
66
+ * and setting an `Accept` header for WSDL requests from Rails apps (savonrb/wasabi#115)
67
+
68
+ ## [2.16.0] - 2026-05-18
25
69
 
26
70
  **Restore compatibility**
27
71
 
28
72
  If you stayed on 2.12.1 because a later version broke something, this release is for you. The fixes below target the most commonly reported upgrade blockers. Existing code should work without modification.
29
73
 
30
- * Fix: Restore `Savon::Response#hash` removed in 2.14.0 (#985). Callers on 2.12.1 that use `response.hash` get the soap body back instead of Ruby's integer object id. A deprecation warning is emitted on each call. Use `#full_hash` going forward.
31
- * Fix: Require wasabi >= 5.1.0 (#1015, #1016). Wasabi 4.x used message names as soap body element names and SOAPAction header values instead of operation names (savonrb/wasabi#122), causing servers to return a fault or reject the action for operations whose message name carried an `In` suffix.
32
- * Fix: Stop dumping all WSDL namespaces into every soap envelope (#1014, #942). 2.13.0 injected every namespace from the entire WSDL document into each request, including structural ones that have no place in a request body. Strict servers reject envelopes with unexpected or duplicate declarations.
33
- * Fix: Raise a proper `SOAPFault` instead of a raw exception when `soap:Fault` contains invalid encoding (#923).
34
- * Fix: `SOAPFault.present?` was ignoring its `xml` argument and always operating on the instance's own body.
35
- * Change: Added Ruby 3.4 (#1024) and Ruby 4.0 (#1039) to the CI test matrix.
74
+ ### Fixed
75
+
76
+ * Restore `Savon::Response#hash` removed in 2.14.0 (#985). Callers on 2.12.1 that use `response.hash` get the soap body back instead of Ruby's integer object id.
77
+ * Require wasabi >= 5.1.0 (#1015, #1016). Wasabi 4.x used message names as soap body element names and SOAPAction header values instead of operation names (savonrb/wasabi#122), causing servers to return a fault or reject the action for operations whose message name carried an `In` suffix.
78
+ * Stop dumping all WSDL namespaces into every soap envelope (#1014, #942). 2.13.0 injected every namespace from the entire WSDL document into each request, including structural ones that have no place in a request body. Strict servers reject envelopes with unexpected or duplicate declarations.
79
+ * Raise a proper `SOAPFault` instead of a raw exception when `soap:Fault` contains invalid encoding (#923).
80
+ * `SOAPFault.present?` was ignoring its `xml` argument and always operating on the instance's own body.
81
+
82
+ ### Changed
83
+
84
+ * Added Ruby 3.4 (#1024) and Ruby 4.0 (#1039) to the CI test matrix.
85
+
86
+ ### Deprecated
87
+
88
+ * `Savon::Response#hash` emits a deprecation warning on each call. Use `#full_hash` going forward. Will be removed in 3.0.
36
89
 
37
90
  ## 2.15.1 (2024-07-08)
38
91
 
@@ -1186,3 +1239,8 @@ Pay attention to the following list and read the updated Wiki: http://wiki.githu
1186
1239
  ## 0.5.0 (2009-11-29)
1187
1240
 
1188
1241
  * Complete rewrite and public release.
1242
+
1243
+ [2.17.2]: https://github.com/savonrb/savon/compare/v2.17.1...v2.17.2
1244
+ [2.17.1]: https://github.com/savonrb/savon/compare/v2.17.0...v2.17.1
1245
+ [2.17.0]: https://github.com/savonrb/savon/compare/v2.16.0...v2.17.0
1246
+ [2.16.0]: https://github.com/savonrb/savon/compare/v2.15.1...v2.16.0
data/README.md CHANGED
@@ -6,7 +6,7 @@ Heavy metal SOAP client
6
6
  [![Gem Version](https://badge.fury.io/rb/savon.svg)](http://badge.fury.io/rb/savon)
7
7
  [![Coverage Status](https://coveralls.io/repos/savonrb/savon/badge.svg)](https://coveralls.io/r/savonrb/savon)
8
8
 
9
- Savon is a SOAP client for Ruby. [SOAP is the protocol](https://www.w3.org/TR/soap/) spoken by many enterprise systems in banking, government, ERP or payroll. When they hand you a WSDL URL or file instead of a REST spec, it's SOAP. Savon reads the WSDL, maps available operations to Ruby symbols, converts Ruby hashes to SOAP envelopes and turns XML responses into hashes you can work with.
9
+ Savon is a SOAP client for Ruby. [SOAP is the protocol](https://www.w3.org/TR/soap/) spoken by many enterprise systems. When they hand you a WSDL URL or file instead of a REST spec, it's SOAP. Savon reads the WSDL, maps available operations to Ruby symbols, converts Ruby hashes to SOAP envelopes and turns XML responses into hashes you can work with.
10
10
 
11
11
  Full documentation is at [savonrb.com](https://savonrb.com).
12
12
 
@@ -68,7 +68,17 @@ Savon uses [HTTPI](https://github.com/savonrb/httpi) for HTTP by default. Since
68
68
 
69
69
  ## Ruby support
70
70
 
71
- Savon 2.x requires Ruby 3.0 or later. See the [changelog](CHANGELOG.md) for historical compatibility.
71
+ Savon 2.x requires Ruby >= 3.0.0, kept as the lower bound for backward compatibility. Note that Ruby 3.0–3.3 are EOL or security-only. Ruby 3.4+ is the minimum version with active maintenance.
72
+
73
+ ## Versioning & stability
74
+
75
+ Savon follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). The changelog format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
76
+
77
+ **The 2.x line is anchored on 2.12.1.** Every 2.x release is supposed to be safe to upgrade to from 2.12.1, and anything that worked in 2.12.1 keeps working. We do not remove or rename public APIs in the 2.x line. New behavior is opt-in and requires an explicit option change. If you found a problem with that, please let us know.
78
+
79
+ We only soft-deprecate APIs we plan to remove. The `Deprecated` sections of the [changelog](CHANGELOG.md) list every API that is planned to be removed with version 3.0.
80
+
81
+ Callers on 2.13.0–2.15.x may see specific post-2.12.1 behaviors restored to the 2.12.1 contract. This is intentional.
72
82
 
73
83
  ## Known limitations
74
84
 
data/lib/savon/builder.rb CHANGED
@@ -70,10 +70,17 @@ module Savon
70
70
  @body_attributes ||= @signature.nil? ? {} : @signature.body_attributes
71
71
  end
72
72
 
73
+ # Returns the request body as a String. When the caller supplies a pre-built
74
+ # envelope via the :xml local option it is used verbatim, but it must still
75
+ # be wrapped in a multipart message when :attachments are present.
76
+ # Otherwise the attachments are silently dropped.
73
77
  def to_s
74
- return @locals[:xml] if @locals.include? :xml
75
-
76
- build_document
78
+ if @locals.include?(:xml)
79
+ xml = @locals[:xml]
80
+ @locals[:attachments] ? build_multipart_message(xml) : xml
81
+ else
82
+ build_document
83
+ end
77
84
  end
78
85
 
79
86
  private
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Savon
6
+ # Formats the replacement Faraday setup for one HTTPI-only option.
7
+ class FaradayMigrationHint
8
+ VALUE_AWARE_OPTIONS = %i[
9
+ proxy
10
+ open_timeout
11
+ read_timeout
12
+ write_timeout
13
+ ssl_version
14
+ ssl_min_version
15
+ ssl_max_version
16
+ ssl_verify_mode
17
+ ssl_cert_key_file
18
+ ssl_cert_file
19
+ ssl_ca_cert_file
20
+ ssl_ciphers
21
+ ssl_ca_cert_path
22
+ adapter
23
+ ].freeze
24
+
25
+ STATIC_HINTS = {
26
+ ssl_cert_key: "client.faraday.ssl.client_key = key",
27
+ ssl_cert_key_password: [
28
+ "key = OpenSSL::PKey.read(File.read(key_path), password)",
29
+ "client.faraday.ssl.client_key = key"
30
+ ],
31
+ ssl_cert: "client.faraday.ssl.client_cert = cert",
32
+ ssl_ca_cert: [
33
+ "store = OpenSSL::X509::Store.new",
34
+ "store.set_default_paths",
35
+ "store.add_cert(cert)",
36
+ "client.faraday.ssl.cert_store = store"
37
+ ],
38
+ ssl_cert_store: "client.faraday.ssl.cert_store = store",
39
+ basic_auth: "client.faraday.request :authorization, :basic, user, pass",
40
+ digest_auth: [
41
+ "gem 'faraday-digestauth'",
42
+ "require 'faraday/digestauth'",
43
+ "client.faraday.request :digest, user, pass"
44
+ ],
45
+ ntlm: [
46
+ "gem 'faraday-ntlm_auth'",
47
+ "require 'faraday/ntlm_auth'",
48
+ "client.faraday.adapter :net_http_persistent",
49
+ "client.faraday.request :ntlm_auth, auth: [user, pass, domain]"
50
+ ],
51
+ follow_redirects: [
52
+ "gem 'faraday-follow_redirects'",
53
+ "require 'faraday/follow_redirects'",
54
+ "client.faraday.response :follow_redirects"
55
+ ]
56
+ }.freeze
57
+
58
+ OPTIONS = (VALUE_AWARE_OPTIONS + STATIC_HINTS.keys).freeze
59
+
60
+ def initialize(option, value)
61
+ @option = option
62
+ @value = value
63
+ end
64
+
65
+ def message
66
+ hint = hint_lines
67
+ return " #{option} - Use: #{hint}" unless hint.is_a?(Array)
68
+
69
+ " #{option} - Use:\n#{hint.map { |line| " #{line}" }.join("\n")}"
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :option, :value
75
+
76
+ def hint_lines
77
+ case option
78
+ when :proxy
79
+ "client.faraday.proxy = #{redacted_proxy_value}"
80
+ when :open_timeout, :read_timeout, :write_timeout
81
+ "client.faraday.options.#{option} = #{value.inspect}"
82
+ when :ssl_version, :ssl_min_version, :ssl_max_version
83
+ ssl_version_hint
84
+ when :ssl_verify_mode
85
+ ssl_verify_mode_hint
86
+ when :ssl_cert_key_file
87
+ ssl_cert_key_file_hint
88
+ when :ssl_cert_file
89
+ ssl_cert_file_hint
90
+ when :ssl_ca_cert_file
91
+ "client.faraday.ssl.ca_file = #{value.inspect}"
92
+ when :ssl_ciphers
93
+ "client.faraday.ssl.ciphers = #{value.inspect}"
94
+ when :ssl_ca_cert_path
95
+ "client.faraday.ssl.ca_path = #{value.inspect}"
96
+ when :adapter
97
+ adapter_hint
98
+ else
99
+ STATIC_HINTS.fetch(option)
100
+ end
101
+ end
102
+
103
+ def redacted_proxy_value
104
+ uri = URI.parse(value.to_s)
105
+ return value.inspect unless uri.absolute? && uri.host
106
+
107
+ redacted = uri.dup
108
+ redacted.userinfo = "..." if redacted.userinfo
109
+ redacted.path = "" if redacted.respond_to?(:path=)
110
+ redacted.query = nil if redacted.respond_to?(:query=)
111
+ redacted.fragment = nil if redacted.respond_to?(:fragment=)
112
+ redacted.to_s.inspect
113
+ rescue URI::InvalidURIError
114
+ '"[redacted proxy URL]"'
115
+ end
116
+
117
+ def ssl_version_hint
118
+ faraday_option = {
119
+ ssl_version: "version",
120
+ ssl_min_version: "min_version",
121
+ ssl_max_version: "max_version"
122
+ }.fetch(option)
123
+
124
+ "client.faraday.ssl.#{faraday_option} = #{value.inspect}"
125
+ end
126
+
127
+ def ssl_cert_key_file_hint
128
+ [
129
+ "key = OpenSSL::PKey.read(File.read(#{value.inspect}))",
130
+ "client.faraday.ssl.client_key = key"
131
+ ]
132
+ end
133
+
134
+ def ssl_cert_file_hint
135
+ [
136
+ "cert = OpenSSL::X509::Certificate.new(File.read(#{value.inspect}))",
137
+ "client.faraday.ssl.client_cert = cert"
138
+ ]
139
+ end
140
+
141
+ def adapter_hint
142
+ case value
143
+ when :net_http
144
+ "client.faraday.adapter :net_http"
145
+ when :httpclient
146
+ [
147
+ "gem 'faraday-httpclient'",
148
+ "require 'faraday/httpclient'",
149
+ "client.faraday.adapter :httpclient"
150
+ ]
151
+ when :excon
152
+ [
153
+ "gem 'faraday-excon'",
154
+ "require 'faraday/excon'",
155
+ "client.faraday.adapter :excon"
156
+ ]
157
+ when :net_http_persistent
158
+ [
159
+ "gem 'faraday-net_http_persistent'",
160
+ "require 'faraday/net_http_persistent'",
161
+ "client.faraday.adapter :net_http_persistent"
162
+ ]
163
+ else
164
+ [
165
+ "choose and install a Faraday adapter matching #{value.inspect}",
166
+ "client.faraday.adapter :net_http"
167
+ ]
168
+ end
169
+ end
170
+
171
+ def ssl_verify_mode_hint
172
+ case value
173
+ when :none
174
+ "client.faraday.ssl.verify_mode = OpenSSL::SSL::VERIFY_NONE"
175
+ when :peer
176
+ "client.faraday.ssl.verify_mode = OpenSSL::SSL::VERIFY_PEER"
177
+ when :fail_if_no_peer_cert
178
+ "client.faraday.ssl.verify_mode = OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT"
179
+ when :client_once
180
+ "client.faraday.ssl.verify_mode = OpenSSL::SSL::VERIFY_CLIENT_ONCE"
181
+ else
182
+ "client.faraday.ssl.verify_mode = OpenSSL::SSL::VERIFY_PEER or OpenSSL::SSL::VERIFY_NONE"
183
+ end
184
+ end
185
+ end
186
+ end
data/lib/savon/model.rb CHANGED
@@ -28,20 +28,25 @@ module Savon
28
28
 
29
29
  # Defines a class-level SOAP operation.
30
30
  def define_class_operation(operation)
31
- class_operation_module.module_eval %{
32
- def #{StringUtils.snakecase(operation.to_s)}(locals = {})
33
- client.call #{operation.inspect}, locals
34
- end
35
- }, __FILE__, __LINE__ - 4 # -4 points to the line where the eval string starts
31
+ method_name = operation_method_name(operation)
32
+
33
+ class_operation_module.define_method(method_name) do |locals = {}|
34
+ client.call operation, locals
35
+ end
36
36
  end
37
37
 
38
38
  # Defines an instance-level SOAP operation.
39
39
  def define_instance_operation(operation)
40
- instance_operation_module.module_eval %{
41
- def #{StringUtils.snakecase(operation.to_s)}(locals = {})
42
- self.class.#{StringUtils.snakecase(operation.to_s)} locals
43
- end
44
- }, __FILE__, __LINE__ - 4 # -4 points to the line where the eval string starts
40
+ method_name = operation_method_name(operation)
41
+
42
+ instance_operation_module.define_method(method_name) do |locals = {}|
43
+ self.class.public_send(method_name, locals)
44
+ end
45
+ end
46
+
47
+ # Returns the generated Ruby method name for a SOAP operation.
48
+ def operation_method_name(operation)
49
+ StringUtils.snakecase(operation.to_s).to_sym
45
50
  end
46
51
 
47
52
  # Class methods.
@@ -177,8 +177,13 @@ module Savon
177
177
  return response if response.is_a?(Transport::Response)
178
178
 
179
179
  if response.is_a?(HTTPI::Response)
180
- warn "Observers returning HTTPI::Response is deprecated - return Savon::Transport::Response instead."
181
- return Transport::Response.from_httpi(response)
180
+ warn "Observers returning HTTPI::Response is deprecated - return Savon::Transport::Response instead.", uplevel: 1
181
+ return Transport::Response.new(
182
+ response.code,
183
+ response.headers,
184
+ response.body,
185
+ cookies: HTTPI::Cookie.list_from_headers(response.headers)
186
+ )
182
187
  end
183
188
 
184
189
  raise Error, "Observers need to return a Savon::Transport::Response " \
data/lib/savon/options.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "logger"
4
4
  require "httpi"
5
+ require "savon/faraday_migration_hint"
5
6
 
6
7
  module Savon
7
8
  # Base class for GlobalOptions and LocalOptions.
@@ -73,46 +74,24 @@ module Savon
73
74
  end
74
75
  end
75
76
 
76
- # HTTPI-specific transport options included in GlobalOptions.
77
+ # Options that belong to HTTPI's transport layer.
77
78
  #
78
- # Every option in this module is handled by HTTPI and has no effect when
79
- # transport: :faraday is set. Faraday callers configure these concerns
80
- # directly on the Faraday::Connection returned by client.faraday.
79
+ # They still work with the default HTTPI transport. With `transport: :faraday`,
80
+ # Savon does not translate them because Faraday exposes its own setup API for
81
+ # proxy, timeout, TLS, auth, redirects, and adapter choices.
81
82
  module HTTPITransportOptions
82
- # Maps each httpi-only option to the Faraday equivalent so the
83
- # error raised at init tells the caller exactly what to do instead.
84
- # Options with a :default entry are only flagged when the caller
85
- # sets a value that differs from the GlobalOptions default.
86
- FARADAY_INCOMPATIBLE_GLOBALS = {
87
- proxy: { hint: "client.faraday.proxy = url" },
88
- open_timeout: { hint: "client.faraday.options.timeout = N" },
89
- read_timeout: { hint: "client.faraday.options.timeout = N" },
90
- write_timeout: { hint: "client.faraday.options.write_timeout = N" },
91
- ssl_version: { hint: "client.faraday.ssl.version = version" },
92
- ssl_min_version: { hint: "client.faraday.ssl.min_version = version" },
93
- ssl_max_version: { hint: "client.faraday.ssl.max_version = version" },
94
- ssl_verify_mode: { hint: "client.faraday.ssl.verify = true/false" },
95
- ssl_cert_key_file: { hint: "client.faraday.ssl.client_key_file = path" },
96
- ssl_cert_key: { hint: "client.faraday.ssl.client_key = key" },
97
- ssl_cert_key_password: { hint: "configure ssl context on client.faraday.ssl" },
98
- ssl_cert_file: { hint: "client.faraday.ssl.client_cert_file = path" },
99
- ssl_cert: { hint: "client.faraday.ssl.client_cert = cert" },
100
- ssl_ca_cert_file: { hint: "client.faraday.ssl.ca_file = path" },
101
- ssl_ca_cert: { hint: "client.faraday.ssl.ca_cert = cert" },
102
- ssl_ciphers: { hint: "client.faraday.ssl.ciphers = ciphers" },
103
- ssl_ca_cert_path: { hint: "client.faraday.ssl.ca_path = path" },
104
- ssl_cert_store: { hint: "client.faraday.ssl.cert_store = store" },
105
- basic_auth: { hint: "client.faraday.request :basic_auth, user, pass" },
106
- digest_auth: { hint: "client.faraday.request :authorization, :Digest, credentials" },
107
- ntlm: { hint: "client.faraday.request :ntlm, user, pass" },
108
- follow_redirects: { hint: "client.faraday.use :follow_redirects", default: false },
109
- adapter: { hint: "client.faraday.adapter :net_http", default: nil }
83
+ TRANSPORT_DEFAULTS = {
84
+ adapter: nil,
85
+ follow_redirects: false
110
86
  }.freeze
111
87
 
112
- # Validates that the chosen transport is compatible with the options set.
113
- # Must be called after all options (including any block-form options) are set.
114
- # Collects every conflict and raises a single InitializationError listing all
115
- # problems and solutions at once.
88
+ # These are rejected for `transport: :faraday` once the caller has set a
89
+ # non-default value. The error points to the matching Faraday setup.
90
+ FARADAY_INCOMPATIBLE_GLOBALS = FaradayMigrationHint::OPTIONS
91
+
92
+ # Runs after all global options have been assigned, including options set in
93
+ # the client block. Reports every HTTPI-only option in one error so callers
94
+ # can move their transport setup to Faraday in one pass.
116
95
  def validate_transport!
117
96
  return unless self[:transport] == :faraday
118
97
 
@@ -122,11 +101,11 @@ module Savon
122
101
  "Add to your Gemfile: gem 'faraday'"
123
102
  end
124
103
 
125
- violations = FARADAY_INCOMPATIBLE_GLOBALS.filter_map { |option, config|
104
+ violations = FARADAY_INCOMPATIBLE_GLOBALS.filter_map { |option|
126
105
  next unless include?(option)
127
- next if config.key?(:default) && self[option] == config[:default]
106
+ next if default_transport_option?(option)
128
107
 
129
- " #{option} - Use: #{config[:hint]}"
108
+ FaradayMigrationHint.new(option, self[option]).message
130
109
  }
131
110
 
132
111
  return if violations.empty?
@@ -252,6 +231,10 @@ module Savon
252
231
 
253
232
  private
254
233
 
234
+ def default_transport_option?(option)
235
+ TRANSPORT_DEFAULTS.key?(option) && self[option] == TRANSPORT_DEFAULTS[option]
236
+ end
237
+
255
238
  # Attempts to load faraday. Returns true if available, false on LoadError.
256
239
  def faraday_loaded?
257
240
  require "faraday"
@@ -271,33 +254,6 @@ module Savon
271
254
 
272
255
  def initialize(options = {})
273
256
  @option_type = :global
274
-
275
- defaults = {
276
- encoding: "UTF-8",
277
- soap_version: 1,
278
- namespaces: {},
279
- logger: Logger.new($stdout),
280
- log: false,
281
- log_headers: true,
282
- filters: [],
283
- pretty_print_xml: false,
284
- raise_errors: true,
285
- strip_namespaces: true,
286
- delete_namespace_attributes: false,
287
- convert_response_tags_to: ->(tag) { StringUtils.snakecase(tag).to_sym },
288
- convert_attributes_to: ->(k, v) { [k, v] },
289
- multipart: false,
290
- use_wsa_headers: false,
291
- no_message_tag: false,
292
- unwrap: false,
293
- host: nil,
294
- transport: :httpi,
295
-
296
- # httpi transport defaults
297
- adapter: nil,
298
- follow_redirects: false
299
- }
300
-
301
257
  options = defaults.merge(options)
302
258
 
303
259
  # this option is a shortcut on the logger which needs to be set
@@ -427,6 +383,26 @@ module Savon
427
383
  @options[:delete_namespace_attributes] = delete_namespace_attributes
428
384
  end
429
385
 
386
+ # The value Nori assigns to empty XML tags in the SOAP response.
387
+ # Defaults to nil, matching Nori's default; set to "" to map empty tags
388
+ # to an empty String instead.
389
+ def empty_tag_value(value)
390
+ @options[:empty_tag_value] = value
391
+ end
392
+
393
+ # Instruct Nori whether to convert dashes in response tag names to
394
+ # underscores before they become Hash keys. Defaults to true.
395
+ def convert_dashes_to_underscores(convert)
396
+ @options[:convert_dashes_to_underscores] = convert
397
+ end
398
+
399
+ # Instruct Nori whether to scrub invalid byte sequences from the response
400
+ # body before parsing it. Defaults to true, which lets responses containing
401
+ # invalid characters still be parsed.
402
+ def scrub_xml(scrub)
403
+ @options[:scrub_xml] = scrub
404
+ end
405
+
430
406
  # Tell Gyoku how to convert Hash key Symbols to XML tags.
431
407
  # Accepts one of :lower_camelcase, :camelcase, :upcase, or :none.
432
408
  def convert_request_keys_to(converter)
@@ -455,6 +431,12 @@ module Savon
455
431
 
456
432
  # Instruct Savon to create a multipart response if available.
457
433
  def multipart(multipart)
434
+ if multipart
435
+ warn "The global :multipart option has been a no-op since v2.13.0. " \
436
+ "Savon detects whether the response is multipart by checking if the " \
437
+ "response Content-Type header contains 'multipart'. You can remove" \
438
+ "this option from your code to make this warning disappear.", uplevel: 1
439
+ end
458
440
  @options[:multipart] = multipart
459
441
  end
460
442
 
@@ -469,12 +451,41 @@ module Savon
469
451
  end
470
452
 
471
453
  # HTTP transport to use. Accepts :httpi (default) or :faraday.
472
- # When set to :faraday, configure transport concerns directly on the
473
- # Faraday::Connection returned by client.faraday instead of using
474
- # the HTTPITransportOptions.
454
+ # With :faraday, set proxy, timeout, TLS, auth, redirect, and adapter
455
+ # details on `client.faraday`; the HTTPI transport options are not copied.
475
456
  def transport(transport)
476
457
  @options[:transport] = transport
477
458
  end
459
+
460
+ private
461
+
462
+ # The default value for every global option.
463
+ def defaults
464
+ HTTPITransportOptions::TRANSPORT_DEFAULTS.merge(
465
+ encoding: "UTF-8",
466
+ soap_version: 1,
467
+ namespaces: {},
468
+ logger: Logger.new($stdout),
469
+ log: false,
470
+ log_headers: true,
471
+ filters: [],
472
+ pretty_print_xml: false,
473
+ raise_errors: true,
474
+ strip_namespaces: true,
475
+ delete_namespace_attributes: false,
476
+ empty_tag_value: nil,
477
+ convert_dashes_to_underscores: true,
478
+ scrub_xml: true,
479
+ convert_response_tags_to: ->(tag) { StringUtils.snakecase(tag).to_sym },
480
+ convert_attributes_to: ->(k, v) { [k, v] },
481
+ multipart: false,
482
+ use_wsa_headers: false,
483
+ no_message_tag: false,
484
+ unwrap: false,
485
+ host: nil,
486
+ transport: :httpi
487
+ )
488
+ end
478
489
  end
479
490
 
480
491
  # Per-request options passed to client.call.
@@ -578,6 +589,12 @@ module Savon
578
589
 
579
590
  # Instruct Savon to create a multipart response if available.
580
591
  def multipart(multipart)
592
+ if multipart
593
+ warn "The local :multipart option has been a no-op since v2.13.0. " \
594
+ "Savon detects whether the response is multipart by checking if the " \
595
+ "response Content-Type header contains 'multipart'. You can remove" \
596
+ "this option from your code to make this warning disappear.", uplevel: 1
597
+ end
581
598
  @options[:multipart] = multipart
582
599
  end
583
600
 
@@ -155,6 +155,9 @@ module Savon
155
155
  nori_options = {
156
156
  delete_namespace_attributes: @globals[:delete_namespace_attributes],
157
157
  strip_namespaces: @globals[:strip_namespaces],
158
+ empty_tag_value: @globals[:empty_tag_value],
159
+ convert_dashes_to_underscores: @globals[:convert_dashes_to_underscores],
160
+ scrub_xml: @globals[:scrub_xml],
158
161
  convert_tags_to: @globals[:convert_response_tags_to],
159
162
  convert_attributes_to: @globals[:convert_attributes_to],
160
163
  advanced_typecasting: @locals[:advanced_typecasting],
@@ -38,13 +38,31 @@ module Savon
38
38
  log_request(url, headers, body) if log?
39
39
 
40
40
  faraday_response = @connection.post(url, body, headers)
41
- response = Response.from_faraday(faraday_response)
41
+ response = Response.new(
42
+ faraday_response.status,
43
+ faraday_response.headers.to_h,
44
+ faraday_response.body,
45
+ cookies: self.class.parse_cookies(faraday_response.headers)
46
+ )
42
47
 
43
48
  log_response(response) if log?
44
-
45
49
  response
46
50
  end
47
51
 
52
+ # Parses Set-Cookie headers into a Hash of name => value. Accepts both
53
+ # the Array and String form. Attributes after the first ';' are discarded.
54
+ def self.parse_cookies(headers)
55
+ raw = headers["set-cookie"] || headers["Set-Cookie"]
56
+ return {} unless raw
57
+
58
+ raw_array = raw.is_a?(Array) ? raw : raw.split(/,\s*/)
59
+ raw_array.each_with_object({}) do |cookie_str, hash|
60
+ name_value = cookie_str.split(";", 2).first.to_s.strip
61
+ name, value = name_value.split("=", 2)
62
+ hash[name] = value if name && !name.empty?
63
+ end
64
+ end
65
+
48
66
  private
49
67
 
50
68
  # Merges all header sources in precedence order:
@@ -58,12 +76,23 @@ module Savon
58
76
  # soap_headers are lowest priority
59
77
  soap_headers.each do |k, v| headers[k] ||= v end
60
78
 
61
- if locals[:cookies]&.any?
62
- headers["Cookie"] = locals[:cookies].map(&:name_and_value).join(";")
63
- end
79
+ cookie_header = format_cookies(locals[:cookies])
80
+ headers["Cookie"] = cookie_header if cookie_header
64
81
 
65
82
  headers
66
83
  end
84
+
85
+ # Builds the Cookie header from a given value.
86
+ # Accepts:
87
+ # * String - passed through verbatim
88
+ # * Hash - formatted as "name=value; name=value" (browser style)
89
+ # Returns nil when no cookies were supplied.
90
+ def format_cookies(cookies)
91
+ return nil if cookies.nil?
92
+ return cookies if cookies.is_a?(String)
93
+
94
+ cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
95
+ end
67
96
  end
68
97
  end
69
98
  end
@@ -34,10 +34,14 @@ module Savon
34
34
  log_request(http_request.url, http_request.headers, http_request.body) if log?
35
35
 
36
36
  http_response = ::HTTPI.post(http_request, @globals[:adapter])
37
- response = Response.from_httpi(http_response)
37
+ response = Response.new(
38
+ http_response.code,
39
+ http_response.headers,
40
+ http_response.body,
41
+ cookies: ::HTTPI::Cookie.list_from_headers(http_response.headers)
42
+ )
38
43
 
39
44
  log_response(response) if log?
40
-
41
45
  response
42
46
  end
43
47
 
@@ -56,14 +60,11 @@ module Savon
56
60
  # soap_headers are lowest priority
57
61
  soap_headers.each do |k, v| headers[k] ||= v end
58
62
 
59
- if locals[:cookies]&.any?
60
- headers["Cookie"] = locals[:cookies].map(&:name_and_value).join(";")
61
- end
62
-
63
63
  http_request = ::HTTPI::Request.new
64
64
  http_request.url = url
65
65
  http_request.body = body
66
66
  http_request.headers = headers
67
+ http_request.set_cookies(locals[:cookies]) if locals[:cookies]
67
68
  configure_http_request(http_request)
68
69
  http_request
69
70
  end
@@ -6,24 +6,20 @@ module Savon
6
6
  #
7
7
  # Every transport produces a Transport::Response so that higher-level code
8
8
  # never depends on transport-specific code. Immutable once constructed.
9
+ #
10
+ # The shape of #cookies is transport-specific: HTTPI responses expose an
11
+ # Array of HTTPI::Cookie, while Faraday responses expose a plain Hash so
12
+ # Faraday users do not depend on HTTPI types.
9
13
  class Response
10
- # Creates a Transport::Response from an HTTPI::Response.
11
- def self.from_httpi(httpi_response)
12
- new(httpi_response.code, httpi_response.headers, httpi_response.body)
13
- end
14
-
15
- # Creates a Transport::Response from a Faraday::Response.
16
- def self.from_faraday(faraday_response)
17
- new(faraday_response.status, faraday_response.headers.to_h, faraday_response.body)
18
- end
19
-
20
14
  # @param code [Integer] HTTP status code
21
15
  # @param headers [Hash] response headers
22
16
  # @param body [String] response body
23
- def initialize(code, headers, body)
17
+ # @param cookies [Object] parsed cookies in a transport-specific shape
18
+ def initialize(code, headers, body, cookies: nil)
24
19
  @code = code
25
20
  @headers = headers
26
21
  @body = body
22
+ @cookies = cookies
27
23
  end
28
24
 
29
25
  # Returns the HTTP status code.
@@ -35,6 +31,10 @@ module Savon
35
31
  # Returns the response body string.
36
32
  attr_reader :body
37
33
 
34
+ # Returns the parsed cookies in a transport-specific shape.
35
+ # See class-level docs.
36
+ attr_reader :cookies
37
+
38
38
  # Returns true when the HTTP status code indicates an error (>= 300).
39
39
  def error?
40
40
  @code >= 300
data/lib/savon/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Savon
4
- VERSION = '2.17.1'
4
+ VERSION = '2.17.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: savon
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.17.1
4
+ version: 2.17.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Harrington
@@ -105,14 +105,14 @@ dependencies:
105
105
  requirements:
106
106
  - - "~>"
107
107
  - !ruby/object:Gem::Version
108
- version: '2.4'
108
+ version: '2.7'
109
109
  type: :runtime
110
110
  prerelease: false
111
111
  version_requirements: !ruby/object:Gem::Requirement
112
112
  requirements:
113
113
  - - "~>"
114
114
  - !ruby/object:Gem::Version
115
- version: '2.4'
115
+ version: '2.7'
116
116
  - !ruby/object:Gem::Dependency
117
117
  name: wasabi
118
118
  requirement: !ruby/object:Gem::Requirement
@@ -151,22 +151,16 @@ dependencies:
151
151
  name: puma
152
152
  requirement: !ruby/object:Gem::Requirement
153
153
  requirements:
154
- - - ">="
155
- - !ruby/object:Gem::Version
156
- version: 4.3.8
157
- - - "<"
154
+ - - "~>"
158
155
  - !ruby/object:Gem::Version
159
- version: '7'
156
+ version: 8.0.2
160
157
  type: :development
161
158
  prerelease: false
162
159
  version_requirements: !ruby/object:Gem::Requirement
163
160
  requirements:
164
- - - ">="
165
- - !ruby/object:Gem::Version
166
- version: 4.3.8
167
- - - "<"
161
+ - - "~>"
168
162
  - !ruby/object:Gem::Version
169
- version: '7'
163
+ version: 8.0.2
170
164
  - !ruby/object:Gem::Dependency
171
165
  name: rack
172
166
  requirement: !ruby/object:Gem::Requirement
@@ -265,6 +259,7 @@ files:
265
259
  - lib/savon/block_interface.rb
266
260
  - lib/savon/builder.rb
267
261
  - lib/savon/client.rb
262
+ - lib/savon/faraday_migration_hint.rb
268
263
  - lib/savon/header.rb
269
264
  - lib/savon/http_error.rb
270
265
  - lib/savon/log_message.rb
@@ -290,7 +285,7 @@ licenses:
290
285
  metadata:
291
286
  rubygems_mfa_required: 'true'
292
287
  source_code_uri: https://github.com/savonrb/savon
293
- changelog_uri: https://github.com/savonrb/savon/blob/v2.x/CHANGELOG.md
288
+ changelog_uri: https://github.com/savonrb/savon/blob/main/CHANGELOG.md
294
289
  bug_tracker_uri: https://github.com/savonrb/savon/issues
295
290
  rdoc_options: []
296
291
  require_paths: