savon 2.16.0 → 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,16 @@ 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
+ }
53
59
 
54
- result.kind_of?(Array) ? result.compact : [result].compact
60
+ result.is_a?(Array) ? result.compact : [result].compact
55
61
  end
56
62
 
57
63
  def hash
@@ -68,12 +74,12 @@ module Savon
68
74
  parse_body unless @has_parsed_body
69
75
  @xml
70
76
  else
71
- @http.body
77
+ http.body
72
78
  end
73
79
  end
74
80
 
75
- alias_method :to_xml, :xml
76
- alias_method :to_s, :xml
81
+ alias to_xml xml
82
+ alias to_s xml
77
83
 
78
84
  def doc
79
85
  @doc ||= Nokogiri.XML(xml)
@@ -107,19 +113,20 @@ module Savon
107
113
 
108
114
  def boundary
109
115
  return unless multipart?
116
+
110
117
  Mail::Field.new('content-type', http.headers['content-type']).parameters['boundary']
111
118
  end
112
119
 
113
120
  def parse_body
114
121
  http.body.force_encoding Encoding::ASCII_8BIT
115
122
  parts = http.body.split(/(?:\A|\r\n)--#{Regexp.escape(boundary)}(?=(?:--)?\s*$)/)
116
- parts[1..-1].to_a.each_with_index do |part, index|
123
+ parts[1..].to_a.each_with_index do |part, index|
117
124
  header_part, body_part = part.lstrip.split(/#{CRLF}#{CRLF}|#{CRLF}#{WSP}*#{CRLF}(?!#{WSP})/m, 2)
118
125
  section = Mail::Part.new(
119
126
  body: body_part
120
127
  )
121
128
  section.header = header_part
122
- if index == 0
129
+ if index.zero?
123
130
  @xml = section.body.to_s
124
131
  else
125
132
  @attachments << section
@@ -129,8 +136,8 @@ module Savon
129
136
  end
130
137
 
131
138
  def build_soap_and_http_errors!
132
- @soap_fault = SOAPFault.new(@http, nori, xml) if soap_fault?
133
- @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?
134
141
  end
135
142
 
136
143
  def raise_soap_and_http_errors!
@@ -139,7 +146,7 @@ module Savon
139
146
  end
140
147
 
141
148
  def raise_invalid_response_error!
142
- raise InvalidResponseError, "Unable to parse response body:\n" + xml.inspect
149
+ raise InvalidResponseError, "Unable to parse response body:\n#{xml.inspect}"
143
150
  end
144
151
 
145
152
  def xml_namespaces
@@ -150,17 +157,19 @@ module Savon
150
157
  return @nori if @nori
151
158
 
152
159
  nori_options = {
153
- :delete_namespace_attributes => @globals[:delete_namespace_attributes],
154
- :strip_namespaces => @globals[:strip_namespaces],
155
- :convert_tags_to => @globals[:convert_response_tags_to],
156
- :convert_attributes_to => @globals[:convert_attributes_to],
157
- :advanced_typecasting => @locals[:advanced_typecasting],
158
- :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]
159
169
  }
160
170
 
161
171
  non_nil_nori_options = nori_options.reject { |_, value| value.nil? }
162
172
  @nori = Nori.new(non_nil_nori_options)
163
173
  end
164
-
165
174
  end
166
175
  end
@@ -1,12 +1,12 @@
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
6
  body = xml || http.body
7
7
  body = body.scrub('') unless body.valid_encoding?
8
8
  fault_node = body.include?("Fault>")
9
- soap1_fault = body.match(/faultcode\/?\>/) && body.match(/faultstring\/?\>/)
9
+ soap1_fault = body.match(%r{faultcode/?>}) && body.match(%r{faultstring/?>})
10
10
  soap2_fault = body.include?("Code>") && body.include?("Reason>")
11
11
 
12
12
  fault_node && (soap1_fault || soap2_fault)
@@ -45,6 +45,5 @@ module Savon
45
45
  "(#{code}) #{text}"
46
46
  end
47
47
  end
48
-
49
48
  end
50
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.16.0'
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"