savon 2.15.1 → 2.17.4

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.
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "nori"
3
4
  require "savon/soap_fault"
4
5
  require "savon/http_error"
@@ -6,33 +7,37 @@ require "savon/http_error"
6
7
  module Savon
7
8
  class Response
8
9
  CRLF = /\r\n/
9
- WSP = /[#{%Q|\x9\x20|}]/
10
+ WSP = /[\t ]/
10
11
 
11
- def initialize(http, globals, locals)
12
- @http = http
13
- @globals = globals
14
- @locals = locals
15
- @attachments = []
16
- @xml = ''
17
- @has_parsed_body = false
12
+ def initialize(transport_response, globals, locals)
13
+ @transport_response = transport_response
14
+ @globals = globals
15
+ @locals = locals
16
+ @attachments = []
17
+ @xml = ''
18
+ @has_parsed_body = false
18
19
 
19
20
  build_soap_and_http_errors!
20
21
  raise_soap_and_http_errors! if @globals[:raise_errors]
21
22
  end
22
23
 
23
- attr_reader :http, :globals, :locals, :soap_fault, :http_error
24
+ attr_reader :globals, :locals, :soap_fault, :http_error
25
+
26
+ def http
27
+ @transport_response
28
+ end
24
29
 
25
30
  def success?
26
31
  !soap_fault? && !http_error?
27
32
  end
28
- alias_method :successful?, :success?
33
+ alias successful? success?
29
34
 
30
35
  def soap_fault?
31
- SOAPFault.present?(@http, xml)
36
+ SOAPFault.present?(http, xml)
32
37
  end
33
38
 
34
39
  def http_error?
35
- HTTPError.present? @http
40
+ HTTPError.present? http
36
41
  end
37
42
 
38
43
  def header
@@ -43,15 +48,21 @@ module Savon
43
48
  find('Body')
44
49
  end
45
50
 
46
- alias_method :to_hash, :body
51
+ alias to_hash body
47
52
 
48
53
  def to_array(*path)
49
- result = path.inject body do |memo, key|
54
+ result = path.inject(body) { |memo, key|
50
55
  return [] if memo[key].nil?
56
+
51
57
  memo[key]
52
- end
58
+ }
59
+
60
+ result.is_a?(Array) ? result.compact : [result].compact
61
+ end
53
62
 
54
- result.kind_of?(Array) ? result.compact : [result].compact
63
+ def hash
64
+ warn "Savon::Response#hash is deprecated and will be removed in version 3 - use #full_hash instead", uplevel: 1
65
+ full_hash
55
66
  end
56
67
 
57
68
  def full_hash
@@ -63,12 +74,12 @@ module Savon
63
74
  parse_body unless @has_parsed_body
64
75
  @xml
65
76
  else
66
- @http.body
77
+ http.body
67
78
  end
68
79
  end
69
80
 
70
- alias_method :to_xml, :xml
71
- alias_method :to_s, :xml
81
+ alias to_xml xml
82
+ alias to_s xml
72
83
 
73
84
  def doc
74
85
  @doc ||= Nokogiri.XML(xml)
@@ -102,19 +113,20 @@ module Savon
102
113
 
103
114
  def boundary
104
115
  return unless multipart?
116
+
105
117
  Mail::Field.new('content-type', http.headers['content-type']).parameters['boundary']
106
118
  end
107
119
 
108
120
  def parse_body
109
121
  http.body.force_encoding Encoding::ASCII_8BIT
110
122
  parts = http.body.split(/(?:\A|\r\n)--#{Regexp.escape(boundary)}(?=(?:--)?\s*$)/)
111
- parts[1..-1].to_a.each_with_index do |part, index|
123
+ parts[1..].to_a.each_with_index do |part, index|
112
124
  header_part, body_part = part.lstrip.split(/#{CRLF}#{CRLF}|#{CRLF}#{WSP}*#{CRLF}(?!#{WSP})/m, 2)
113
125
  section = Mail::Part.new(
114
126
  body: body_part
115
127
  )
116
128
  section.header = header_part
117
- if index == 0
129
+ if index.zero?
118
130
  @xml = section.body.to_s
119
131
  else
120
132
  @attachments << section
@@ -124,8 +136,8 @@ module Savon
124
136
  end
125
137
 
126
138
  def build_soap_and_http_errors!
127
- @soap_fault = SOAPFault.new(@http, nori, xml) if soap_fault?
128
- @http_error = HTTPError.new(@http) if http_error?
139
+ @soap_fault = SOAPFault.new(http, nori, xml) if soap_fault?
140
+ @http_error = HTTPError.new(http) if http_error?
129
141
  end
130
142
 
131
143
  def raise_soap_and_http_errors!
@@ -134,7 +146,7 @@ module Savon
134
146
  end
135
147
 
136
148
  def raise_invalid_response_error!
137
- raise InvalidResponseError, "Unable to parse response body:\n" + xml.inspect
149
+ raise InvalidResponseError, "Unable to parse response body:\n#{xml.inspect}"
138
150
  end
139
151
 
140
152
  def xml_namespaces
@@ -145,17 +157,19 @@ module Savon
145
157
  return @nori if @nori
146
158
 
147
159
  nori_options = {
148
- :delete_namespace_attributes => @globals[:delete_namespace_attributes],
149
- :strip_namespaces => @globals[:strip_namespaces],
150
- :convert_tags_to => @globals[:convert_response_tags_to],
151
- :convert_attributes_to => @globals[:convert_attributes_to],
152
- :advanced_typecasting => @locals[:advanced_typecasting],
153
- :parser => @locals[:response_parser]
160
+ delete_namespace_attributes: @globals[:delete_namespace_attributes],
161
+ strip_namespaces: @globals[:strip_namespaces],
162
+ empty_tag_value: @globals[:empty_tag_value],
163
+ convert_dashes_to_underscores: @globals[:convert_dashes_to_underscores],
164
+ scrub_xml: @globals[:scrub_xml],
165
+ convert_tags_to: @globals[:convert_response_tags_to],
166
+ convert_attributes_to: @globals[:convert_attributes_to],
167
+ advanced_typecasting: @locals[:advanced_typecasting],
168
+ parser: @locals[:response_parser]
154
169
  }
155
170
 
156
171
  non_nil_nori_options = nori_options.reject { |_, value| value.nil? }
157
172
  @nori = Nori.new(non_nil_nori_options)
158
173
  end
159
-
160
174
  end
161
175
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Savon
3
4
  class SOAPFault < Error
4
-
5
5
  def self.present?(http, xml = nil)
6
- xml ||= http.body
7
- fault_node = xml.include?("Fault>")
8
- soap1_fault = xml.match(/faultcode\/?\>/) && xml.match(/faultstring\/?\>/)
9
- soap2_fault = xml.include?("Code>") && xml.include?("Reason>")
6
+ body = xml || http.body
7
+ body = body.scrub('') unless body.valid_encoding?
8
+ fault_node = body.include?("Fault>")
9
+ soap1_fault = body.match(%r{faultcode/?>}) && body.match(%r{faultstring/?>})
10
+ soap2_fault = body.include?("Code>") && body.include?("Reason>")
10
11
 
11
12
  fault_node && (soap1_fault || soap2_fault)
12
13
  end
@@ -44,6 +45,5 @@ module Savon
44
45
  "(#{code}) #{text}"
45
46
  end
46
47
  end
47
-
48
48
  end
49
49
  end
@@ -4,9 +4,9 @@ module Savon
4
4
  module StringUtils
5
5
  def self.snakecase(inputstring)
6
6
  str = inputstring.dup
7
- str.gsub! /::/, '/'
8
- str.gsub! /([A-Z]+)([A-Z][a-z])/, '\1_\2'
9
- str.gsub! /([a-z\d])([A-Z])/, '\1_\2'
7
+ str.gsub!(/::/, '/')
8
+ str.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
9
+ str.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
10
10
  str.tr! ".", "_"
11
11
  str.tr! "-", "_"
12
12
  str.downcase!
@@ -14,4 +14,3 @@ module Savon
14
14
  end
15
15
  end
16
16
  end
17
-
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "savon/transport/logging"
4
+ require "savon/transport/response"
5
+
6
+ module Savon
7
+ module Transport
8
+ # Faraday-backed HTTP transport for the opt-in Faraday path.
9
+ #
10
+ # Encapsulates everything Faraday-specific:
11
+ # * header assembly (SOAP + global + local + cookies)
12
+ # * request execution via the caller-configured Faraday::Connection
13
+ #
14
+ # Transport-level concerns (SSL, auth, proxy, timeouts, middleware) are
15
+ # the caller's responsibility via client.faraday before any call is made.
16
+ class Faraday
17
+ include Logging
18
+
19
+ # @param connection [Faraday::Connection] the memoized connection from client.faraday
20
+ # @param globals [Savon::GlobalOptions] the client-level options
21
+ def initialize(connection, globals)
22
+ @connection = connection
23
+ @globals = globals
24
+ end
25
+
26
+ # Assembles headers, executes the POST via the Faraday connection, and
27
+ # returns a Transport::Response. Logs the outbound request and inbound
28
+ # response when logging is enabled.
29
+ #
30
+ # @param url [String] the SOAP endpoint URL
31
+ # @param soap_headers [Hash] SOAP-level headers (Content-Type, SOAPAction, etc.)
32
+ # @param body [String] the serialized SOAP envelope
33
+ # @param locals [Savon::LocalOptions] per-request options
34
+ # @return [Transport::Response]
35
+ def post(url, soap_headers, body, locals)
36
+ headers = build_headers(soap_headers, locals)
37
+
38
+ log_request(url, headers, body) if log?
39
+
40
+ faraday_response = @connection.post(url, body, headers)
41
+ response = normalize_response(faraday_response)
42
+
43
+ log_response(response) if log?
44
+ response
45
+ end
46
+
47
+ # Normalizes a native Faraday::Response into a Transport::Response.
48
+ #
49
+ # @param faraday_response [Faraday::Response]
50
+ # @return [Transport::Response]
51
+ def normalize_response(faraday_response)
52
+ Response.from_faraday(faraday_response)
53
+ end
54
+
55
+ # Parses Set-Cookie headers into a Hash of name => value. Accepts both
56
+ # the Array and String form. Attributes after the first ';' are discarded.
57
+ def self.parse_cookies(headers)
58
+ raw = headers["set-cookie"] || headers["Set-Cookie"]
59
+ return {} unless raw
60
+
61
+ raw_array = raw.is_a?(Array) ? raw : raw.split(/,\s*/)
62
+ raw_array.each_with_object({}) do |cookie_str, hash|
63
+ name_value = cookie_str.split(";", 2).first.to_s.strip
64
+ name, value = name_value.split("=", 2)
65
+ hash[name] = value if name && !name.empty?
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # Merges all header sources in precedence order:
72
+ # locals[:headers] > globals[:headers] > soap_headers
73
+ # Appends Cookie from locals[:cookies].
74
+ def build_headers(soap_headers, locals)
75
+ headers = {}
76
+ headers.merge!(@globals[:headers]) if @globals.include?(:headers)
77
+ headers.merge!(locals[:headers]) if locals.include?(:headers)
78
+
79
+ # soap_headers are lowest priority
80
+ soap_headers.each do |k, v| headers[k] ||= v end
81
+
82
+ cookie_header = format_cookies(locals[:cookies])
83
+ headers["Cookie"] = cookie_header if cookie_header
84
+
85
+ headers
86
+ end
87
+
88
+ # Builds the Cookie header from a given value.
89
+ # Accepts:
90
+ # * String - passed through verbatim
91
+ # * Hash - formatted as "name=value; name=value" (browser style)
92
+ # Returns nil when no cookies were supplied.
93
+ def format_cookies(cookies)
94
+ return nil if cookies.nil?
95
+ return cookies if cookies.is_a?(String)
96
+
97
+ cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httpi"
4
+ require "savon/transport/logging"
5
+ require "savon/transport/response"
6
+
7
+ module Savon
8
+ module Transport
9
+ # HTTPI-backed HTTP transport for the default HTTP path.
10
+ #
11
+ # Encapsulates everything HTTPI-specific:
12
+ # * header assembly
13
+ # * proxy/SSL/auth/timeout configuration
14
+ # * request execution
15
+ class HTTPI
16
+ include Logging
17
+
18
+ # @param globals [Savon::GlobalOptions] the client-level options
19
+ def initialize(globals)
20
+ @globals = globals
21
+ end
22
+
23
+ # Assembles and executes a SOAP request, returning a Transport::Response.
24
+ # Logs the outbound request and inbound response when logging is enabled.
25
+ #
26
+ # @param url [String] the SOAP endpoint URL
27
+ # @param soap_headers [Hash] SOAP-level headers
28
+ # @param body [String] the serialized SOAP envelope
29
+ # @param locals [Savon::LocalOptions] per-request options
30
+ # @return [Transport::Response]
31
+ def post(url, soap_headers, body, locals)
32
+ http_request = to_httpi_request(url, soap_headers, body, locals)
33
+
34
+ log_request(http_request.url, http_request.headers, http_request.body) if log?
35
+
36
+ httpi_response = ::HTTPI.post(http_request, @globals[:adapter])
37
+ response = normalize_response(httpi_response)
38
+
39
+ log_response(response) if log?
40
+ response
41
+ end
42
+
43
+ # Normalizes a native HTTPI::Response into a Transport::Response.
44
+ #
45
+ # @param httpi_response [HTTPI::Response]
46
+ # @return [Transport::Response]
47
+ def normalize_response(httpi_response)
48
+ Response.from_httpi(httpi_response)
49
+ end
50
+
51
+ # Builds a fully-configured HTTPI::Request.
52
+ #
53
+ # @param url [String] the SOAP endpoint URL
54
+ # @param soap_headers [Hash] SOAP-level headers
55
+ # @param body [String] the serialized SOAP envelope
56
+ # @param locals [Savon::LocalOptions] per-request options
57
+ # @return [HTTPI::Request]
58
+ def to_httpi_request(url, soap_headers, body, locals)
59
+ headers = {}
60
+ headers.merge!(@globals[:headers]) if @globals.include?(:headers)
61
+ headers.merge!(locals[:headers]) if locals.include?(:headers)
62
+
63
+ # soap_headers are lowest priority
64
+ soap_headers.each do |k, v| headers[k] ||= v end
65
+
66
+ http_request = ::HTTPI::Request.new
67
+ http_request.url = url
68
+ http_request.body = body
69
+ http_request.headers = headers
70
+ http_request.set_cookies(locals[:cookies]) if locals[:cookies]
71
+ configure_http_request(http_request)
72
+ http_request
73
+ end
74
+
75
+ # Returns a configured HTTPI::Request for Wasabi's WSDL resolver.
76
+ # Applies global headers and all transport-level options and
77
+ # leaves the rest to Wasabi.
78
+ #
79
+ # @return [HTTPI::Request]
80
+ def wsdl_request
81
+ http_request = ::HTTPI::Request.new
82
+ http_request.headers = @globals[:headers].dup if @globals.include?(:headers)
83
+ configure_http_request(http_request)
84
+ http_request
85
+ end
86
+
87
+ private
88
+
89
+ def configure_http_request(http_request)
90
+ configure_proxy(http_request)
91
+ configure_timeouts(http_request)
92
+ configure_ssl(http_request)
93
+ configure_auth(http_request)
94
+ configure_redirect_handling(http_request)
95
+ end
96
+
97
+ def configure_proxy(http_request)
98
+ http_request.proxy = @globals[:proxy] if @globals.include?(:proxy)
99
+ end
100
+
101
+ def configure_timeouts(http_request)
102
+ http_request.open_timeout = @globals[:open_timeout] if @globals.include?(:open_timeout)
103
+ http_request.read_timeout = @globals[:read_timeout] if @globals.include?(:read_timeout)
104
+ http_request.write_timeout = @globals[:write_timeout] if @globals.include?(:write_timeout)
105
+ end
106
+
107
+ # Configures SSL on the HTTPI::Request from all ssl globals.
108
+ # SSL option reference: https://github.com/savonrb/httpi/blob/main/lib/httpi/auth/ssl.rb
109
+ def configure_ssl(http_request)
110
+ ssl = http_request.auth.ssl
111
+ ssl.ssl_version = @globals[:ssl_version] if @globals.include?(:ssl_version)
112
+ ssl.min_version = @globals[:ssl_min_version] if @globals.include?(:ssl_min_version)
113
+ ssl.max_version = @globals[:ssl_max_version] if @globals.include?(:ssl_max_version)
114
+ ssl.verify_mode = @globals[:ssl_verify_mode] if @globals.include?(:ssl_verify_mode)
115
+ ssl.ciphers = @globals[:ssl_ciphers] if @globals.include?(:ssl_ciphers)
116
+ ssl.cert_key_file = @globals[:ssl_cert_key_file] if @globals.include?(:ssl_cert_key_file)
117
+ ssl.cert_key = @globals[:ssl_cert_key] if @globals.include?(:ssl_cert_key)
118
+ ssl.cert_file = @globals[:ssl_cert_file] if @globals.include?(:ssl_cert_file)
119
+ ssl.cert = @globals[:ssl_cert] if @globals.include?(:ssl_cert)
120
+ ssl.ca_cert_file = @globals[:ssl_ca_cert_file] if @globals.include?(:ssl_ca_cert_file)
121
+ ssl.ca_cert_path = @globals[:ssl_ca_cert_path] if @globals.include?(:ssl_ca_cert_path)
122
+ ssl.ca_cert = @globals[:ssl_ca_cert] if @globals.include?(:ssl_ca_cert)
123
+ ssl.cert_store = @globals[:ssl_cert_store] if @globals.include?(:ssl_cert_store)
124
+ ssl.cert_key_password = @globals[:ssl_cert_key_password] if @globals.include?(:ssl_cert_key_password)
125
+ end
126
+
127
+ def configure_auth(http_request)
128
+ http_request.auth.basic(*@globals[:basic_auth]) if @globals.include?(:basic_auth)
129
+ http_request.auth.digest(*@globals[:digest_auth]) if @globals.include?(:digest_auth)
130
+ http_request.auth.ntlm(*@globals[:ntlm]) if @globals.include?(:ntlm)
131
+ end
132
+
133
+ def configure_redirect_handling(http_request)
134
+ http_request.follow_redirect = @globals[:follow_redirects] if @globals.include?(:follow_redirects)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "savon/log_message"
4
+
5
+ module Savon
6
+ module Transport
7
+ # Shared logging behaviour for HTTP transports.
8
+ #
9
+ # Expects the including class to expose @globals (Savon::GlobalOptions)
10
+ # so that log level, filters, and pretty-print settings can be accessed.
11
+ #
12
+ # log_request and log_response are intentionally private so that each
13
+ # transport drives them from its own post method.
14
+ module Logging
15
+ private
16
+
17
+ def log?
18
+ @globals[:log]
19
+ end
20
+
21
+ def log_headers?
22
+ @globals[:log_headers]
23
+ end
24
+
25
+ def logger
26
+ @globals[:logger]
27
+ end
28
+
29
+ # Logs the outbound request at INFO (URL and optional headers)
30
+ # and DEBUG (filtered/pretty-printed body).
31
+ #
32
+ # @param url [String] the SOAP endpoint URL
33
+ # @param headers [Hash] request headers
34
+ # @param body [String] the serialized SOAP envelope
35
+ def log_request(url, headers, body)
36
+ logger.info do "SOAP request: #{url}" end
37
+ logger.info { headers_to_log(headers) } if log_headers?
38
+ logger.debug { body_to_log(body) }
39
+ end
40
+
41
+ # Logs the inbound response at INFO (status line)
42
+ # and DEBUG (headers and filtered/pretty-printed body).
43
+ #
44
+ # @param response [Transport::Response]
45
+ def log_response(response)
46
+ logger.info do "SOAP response (status #{response.code})" end
47
+ logger.debug { headers_to_log(response.headers) } if log_headers?
48
+ logger.debug { body_to_log(response.body) }
49
+ end
50
+
51
+ def headers_to_log(headers)
52
+ headers.map { |key, value| "#{key}: #{value}" }.join("\n")
53
+ end
54
+
55
+ def body_to_log(body)
56
+ LogMessage.new(body, @globals[:filters], @globals[:pretty_print_xml]).to_s
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httpi"
4
+ require "savon/transport/faraday"
5
+
6
+ module Savon
7
+ module Transport
8
+ # Transport-agnostic HTTP response value object.
9
+ #
10
+ # Every transport produces a Transport::Response so that higher-level code
11
+ # never depends on transport-specific code. Immutable once constructed.
12
+ #
13
+ # The shape of #cookies is transport-specific: HTTPI responses expose an
14
+ # Array of HTTPI::Cookie, while Faraday responses expose a plain Hash so
15
+ # Faraday users do not depend on HTTPI types.
16
+ class Response
17
+ # Builds a Response from an HTTPI::Response.
18
+ #
19
+ # @param httpi_response [HTTPI::Response]
20
+ # @return [Transport::Response]
21
+ def self.from_httpi(httpi_response)
22
+ new(
23
+ httpi_response.code,
24
+ httpi_response.headers,
25
+ httpi_response.body,
26
+ cookies: ::HTTPI::Cookie.list_from_headers(httpi_response.headers)
27
+ )
28
+ end
29
+
30
+ # Builds a Response from a Faraday::Response.
31
+ #
32
+ # @param faraday_response [Faraday::Response]
33
+ # @return [Transport::Response]
34
+ def self.from_faraday(faraday_response)
35
+ new(
36
+ faraday_response.status,
37
+ faraday_response.headers.to_h,
38
+ faraday_response.body,
39
+ cookies: Savon::Transport::Faraday.parse_cookies(faraday_response.headers)
40
+ )
41
+ end
42
+
43
+ # @param code [Integer] HTTP status code
44
+ # @param headers [Hash] response headers
45
+ # @param body [String] response body
46
+ # @param cookies [Object] parsed cookies in a transport-specific shape
47
+ def initialize(code, headers, body, cookies: nil)
48
+ @code = code
49
+ @headers = headers
50
+ @body = body
51
+ @cookies = cookies
52
+ end
53
+
54
+ # Returns the HTTP status code.
55
+ attr_reader :code
56
+
57
+ # Returns the response headers hash.
58
+ attr_reader :headers
59
+
60
+ # Returns the response body string.
61
+ attr_reader :body
62
+
63
+ # Returns the parsed cookies in a transport-specific shape.
64
+ # See class-level docs.
65
+ attr_reader :cookies
66
+
67
+ # Returns true when the HTTP status code indicates an error (>= 300).
68
+ def error?
69
+ @code >= 300
70
+ end
71
+ end
72
+ end
73
+ end
data/lib/savon/version.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Savon
3
- VERSION = '2.15.1'
4
+ VERSION = '2.17.4'
4
5
  end
data/lib/savon.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
- module Savon
3
2
 
3
+ module Savon
4
4
  Error = Class.new(RuntimeError)
5
5
  InitializationError = Class.new(Error)
6
6
  UnknownOptionError = Class.new(Error)
@@ -16,11 +16,10 @@ module Savon
16
16
  end
17
17
 
18
18
  def self.notify_observers(operation_name, builder, globals, locals)
19
- observers.inject(nil) do |response, observer|
19
+ observers.inject(nil) do |_response, observer|
20
20
  observer.notify(operation_name, builder, globals, locals)
21
21
  end
22
22
  end
23
-
24
23
  end
25
24
 
26
25
  require "savon/version"