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,11 +1,18 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "httpi"
4
+ require "savon/transport/response"
3
5
 
4
6
  module Savon
7
+ # A single test expectation set up by Savon's mock interface.
8
+ # One expectation covers one operation call in one test.
9
+ #
10
+ # Records the expected operation name and message, captures what was
11
+ # actually called, and either returns a synthetic response or raises
12
+ # an error on mismatch.
5
13
  class MockExpectation
6
-
7
14
  def initialize(operation_name)
8
- @expected = { :operation_name => operation_name }
15
+ @expected = { operation_name: operation_name }
9
16
  @actual = nil
10
17
  end
11
18
 
@@ -15,15 +22,15 @@ module Savon
15
22
  end
16
23
 
17
24
  def returns(response)
18
- response = { :code => 200, :headers => {}, :body => response } if response.kind_of?(String)
25
+ response = { code: 200, headers: {}, body: response } if response.is_a?(String)
19
26
  @response = response
20
27
  self
21
28
  end
22
29
 
23
- def actual(operation_name, builder, globals, locals)
30
+ def actual(operation_name, _builder, _globals, locals)
24
31
  @actual = {
25
- :operation_name => operation_name,
26
- :message => locals[:message]
32
+ operation_name: operation_name,
33
+ message: locals[:message]
27
34
  }
28
35
  end
29
36
 
@@ -37,45 +44,52 @@ module Savon
37
44
  verify_message!
38
45
  end
39
46
 
47
+ # Builds and returns a Transport::Response from the configured response hash.
48
+ #
49
+ # @return [Transport::Response]
50
+ # @raise [ExpectationError] if no response was configured for this expectation
40
51
  def response!
41
52
  unless @response
42
53
  raise ExpectationError, "This expectation was not set up with a response."
43
54
  end
44
55
 
45
- HTTPI::Response.new(@response[:code], @response[:headers], @response[:body])
56
+ Transport::Response.new(@response[:code], @response[:headers], @response[:body])
46
57
  end
47
58
 
48
59
  private
49
60
 
50
61
  def verify_operation_name!
51
- unless @expected[:operation_name] == @actual[:operation_name]
52
- raise ExpectationError, "Expected a request to the #{@expected[:operation_name].inspect} operation.\n" \
53
- "Received a request to the #{@actual[:operation_name].inspect} operation instead."
54
- end
62
+ return if @expected[:operation_name] == @actual[:operation_name]
63
+
64
+ raise ExpectationError, "Expected a request to the #{@expected[:operation_name].inspect} operation.\n" \
65
+ "Received a request to the #{@actual[:operation_name].inspect} operation instead."
55
66
  end
56
67
 
57
68
  def verify_message!
58
69
  return if @expected[:message].eql? :any
59
- unless equals_except_any(@expected[:message], @actual[:message])
60
- expected_message = " with this message: #{@expected[:message].inspect}" if @expected[:message]
61
- expected_message ||= " with no message."
62
70
 
63
- actual_message = " with this message: #{@actual[:message].inspect}" if @actual[:message]
64
- actual_message ||= " with no message."
71
+ return if equals_except_any(@expected[:message], @actual[:message])
65
72
 
66
- raise ExpectationError, "Expected a request to the #{@expected[:operation_name].inspect} operation\n#{expected_message}\n" \
67
- "Received a request to the #{@actual[:operation_name].inspect} operation\n#{actual_message}"
68
- end
73
+ expected_message = " with this message: #{@expected[:message].inspect}" if @expected[:message]
74
+ expected_message ||= " with no message."
75
+
76
+ actual_message = " with this message: #{@actual[:message].inspect}" if @actual[:message]
77
+ actual_message ||= " with no message."
78
+
79
+ raise ExpectationError, "Expected a request to the #{@expected[:operation_name].inspect} operation\n#{expected_message}\n" \
80
+ "Received a request to the #{@actual[:operation_name].inspect} operation\n#{actual_message}"
69
81
  end
70
82
 
71
83
  def equals_except_any(msg_expected, msg_real)
72
- return true if msg_expected === msg_real
73
- return false if (msg_expected.nil? || msg_real.nil?) # If both are nil has returned true
84
+ # === allows RSpec matchers (e.g. include(:key)) to be used as expected values
85
+ return true if msg_expected === msg_real # rubocop:disable Style/CaseEquality
86
+ return false if msg_expected.nil? || msg_real.nil? # If both are nil has returned true
87
+
74
88
  msg_expected.each do |key, expected_value|
75
- next if (expected_value == :any && msg_real.include?(key))
89
+ next if expected_value == :any && msg_real.include?(key)
76
90
  return false if expected_value != msg_real[key]
77
91
  end
78
- return true
92
+ true
79
93
  end
80
94
  end
81
95
  end
@@ -1,11 +1,10 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "savon/mock"
3
4
 
4
5
  module Savon
5
6
  module SpecHelper
6
-
7
7
  class Interface
8
-
9
8
  def mock!
10
9
  Savon.observers << self
11
10
  end
@@ -27,14 +26,12 @@ module Savon
27
26
  def notify(operation_name, builder, globals, locals)
28
27
  expectation = expectations.shift
29
28
 
30
- if expectation
31
- expectation.actual(operation_name, builder, globals, locals)
29
+ raise ExpectationError, "Unexpected request to the #{operation_name.inspect} operation." unless expectation
30
+
31
+ expectation.actual(operation_name, builder, globals, locals)
32
32
 
33
- expectation.verify!
34
- expectation.response!
35
- else
36
- raise ExpectationError, "Unexpected request to the #{operation_name.inspect} operation."
37
- end
33
+ expectation.verify!
34
+ expectation.response!
38
35
  rescue ExpectationError
39
36
  @expectations.clear
40
37
  raise
@@ -42,12 +39,12 @@ module Savon
42
39
 
43
40
  def verify!
44
41
  return if expectations.empty?
42
+
45
43
  expectations.each(&:verify!)
46
44
  rescue ExpectationError
47
45
  @expectations.clear
48
46
  raise
49
47
  end
50
-
51
48
  end
52
49
 
53
50
  def savon
@@ -58,6 +55,5 @@ module Savon
58
55
  super if defined? super
59
56
  savon.verify!
60
57
  end
61
-
62
58
  end
63
59
  end
data/lib/savon/mock.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Savon
3
4
  class ExpectationError < StandardError; end
4
5
  end
data/lib/savon/model.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Savon
3
4
  module Model
4
-
5
5
  def self.extended(base)
6
6
  base.setup
7
7
  end
@@ -28,26 +28,30 @@ 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
- }
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
- }
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.
48
53
  def class_operation_module
49
- @class_operation_module ||= Module.new {
50
-
54
+ @class_operation_module ||= Module.new do
51
55
  def client(globals = {})
52
56
  @client ||= Savon::Client.new(globals)
53
57
  rescue InitializationError
@@ -60,26 +64,22 @@ module Savon
60
64
 
61
65
  def raise_initialization_error!
62
66
  raise InitializationError,
63
- "Expected the model to be initialized with either a WSDL document or the SOAP endpoint and target namespace options.\n" \
64
- "Make sure to setup the model by calling the .client class method before calling the .global method.\n\n" \
65
- "client(wsdl: '/Users/me/project/service.wsdl') # to use a local WSDL document\n" \
66
- "client(wsdl: 'http://example.com?wsdl') # to use a remote WSDL document\n" \
67
- "client(endpoint: 'http://example.com', namespace: 'http://v1.example.com') # if you don't have a WSDL document"
67
+ "Expected the model to be initialized with either a WSDL document or the SOAP endpoint and target namespace options.\n" \
68
+ "Make sure to setup the model by calling the .client class method before calling the .global method.\n\n" \
69
+ "client(wsdl: '/Users/me/project/service.wsdl') # to use a local WSDL document\n" \
70
+ "client(wsdl: 'http://example.com?wsdl') # to use a remote WSDL document\n" \
71
+ "client(endpoint: 'http://example.com', namespace: 'http://v1.example.com') # if you don't have a WSDL document"
68
72
  end
69
-
70
- }.tap { |mod| extend(mod) }
73
+ end.tap { |mod| extend(mod) }
71
74
  end
72
75
 
73
76
  # Instance methods.
74
77
  def instance_operation_module
75
- @instance_operation_module ||= Module.new {
76
-
78
+ @instance_operation_module ||= Module.new do
77
79
  def client
78
80
  self.class.client
79
81
  end
80
-
81
- }.tap { |mod| include(mod) }
82
+ end.tap { |mod| include(mod) }
82
83
  end
83
-
84
84
  end
85
85
  end
@@ -1,52 +1,67 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "savon/options"
4
+ require "savon/effective_options"
3
5
  require "savon/block_interface"
4
- require "savon/request"
5
6
  require "savon/builder"
6
7
  require "savon/response"
7
- require "savon/request_logger"
8
8
  require "savon/http_error"
9
+ require "savon/transport/httpi"
10
+ require "savon/transport/faraday"
9
11
  require "mail"
10
12
 
11
13
  module Savon
14
+ # Represents a single named SOAP operation.
15
+ #
16
+ # Bridges the SOAP layer (envelope building, action headers, multipart) and the
17
+ # transport layer (execution, logging). Knows nothing about transport internals
18
+ # such as proxy, SSL, or auth.
12
19
  class Operation
13
-
20
+ # SOAP Content-Type values indexed by SOAP version.
21
+ # SOAP 1.1 §6 (HTTP binding), SOAP 1.2 Part 2 §7.1.4 (HTTP media type)
22
+ CONTENT_TYPE = {
23
+ 1 => "text/xml;charset=%s",
24
+ 2 => "application/soap+xml;charset=%s"
25
+ }.freeze
26
+
27
+ # Maps SOAP version to the base MIME type used in multipart requests.
28
+ # RFC 2387 §3.1 (multipart/related Content-Type parameter)
14
29
  SOAP_REQUEST_TYPE = {
15
30
  1 => "text/xml",
16
31
  2 => "application/soap+xml"
17
- }
32
+ }.freeze
18
33
 
19
- def self.create(operation_name, wsdl, globals)
34
+ def self.create(operation_name, wsdl, globals, transport)
20
35
  if wsdl.document?
21
36
  ensure_name_is_symbol! operation_name
22
- ensure_exists! operation_name, wsdl
37
+ ensure_exists! operation_name, wsdl, transport
23
38
  end
24
39
 
25
- new(operation_name, wsdl, globals)
40
+ new(operation_name, wsdl, globals, transport)
26
41
  end
27
42
 
28
- def self.ensure_exists!(operation_name, wsdl)
43
+ # Verifies the operation is provided by the WSDL.
44
+ def self.ensure_exists!(operation_name, wsdl, transport)
29
45
  unless wsdl.soap_actions.include? operation_name
30
46
  raise UnknownOperationError, "Unable to find SOAP operation: #{operation_name.inspect}\n" \
31
47
  "Operations provided by your service: #{wsdl.soap_actions.inspect}"
32
48
  end
33
49
  rescue Wasabi::Resolver::HTTPError => e
34
- raise HTTPError.new(e.response)
50
+ raise HTTPError, transport.normalize_response(e.response)
35
51
  end
36
52
 
37
53
  def self.ensure_name_is_symbol!(operation_name)
38
- unless operation_name.kind_of? Symbol
39
- raise ArgumentError, "Expected the first parameter (the name of the operation to call) to be a symbol\n" \
40
- "Actual: #{operation_name.inspect} (#{operation_name.class})"
41
- end
42
- end
54
+ return if operation_name.is_a? Symbol
43
55
 
44
- def initialize(name, wsdl, globals)
45
- @name = name
46
- @wsdl = wsdl
47
- @globals = globals
56
+ raise ArgumentError, "Expected the first parameter (the name of the operation to call) to be a symbol\n" \
57
+ "Actual: #{operation_name.inspect} (#{operation_name.class})"
58
+ end
48
59
 
49
- @logger = RequestLogger.new(globals)
60
+ def initialize(name, wsdl, globals, transport)
61
+ @name = name
62
+ @wsdl = wsdl
63
+ @globals = globals
64
+ @transport = transport
50
65
  end
51
66
 
52
67
  def build(locals = {}, &block)
@@ -54,28 +69,45 @@ module Savon
54
69
  Builder.new(@name, @wsdl, @globals, @locals)
55
70
  end
56
71
 
72
+ # Executes the SOAP operation and returns a Savon::Response.
73
+ #
74
+ # Observer short-circuit: if any registered observer returns a
75
+ # Transport::Response (or legacy HTTPI::Response), the HTTP call
76
+ # is skipped and that response is used directly.
57
77
  def call(locals = {}, &block)
58
78
  builder = build(locals, &block)
79
+ observer_response = Savon.notify_observers(@name, builder, @globals, @locals)
80
+
81
+ transport_response =
82
+ if observer_response.nil?
83
+ body = builder.to_s
84
+ headers = soap_headers(builder)
85
+ @transport.post(endpoint.to_s, headers, body, @locals)
86
+ else
87
+ normalize_observer_response(observer_response)
88
+ end
59
89
 
60
- response = Savon.notify_observers(@name, builder, @globals, @locals)
61
- response ||= call_with_logging build_request(builder)
62
-
63
- raise_expected_httpi_response! unless response.kind_of?(HTTPI::Response)
64
-
65
- create_response(response)
90
+ Response.new(transport_response, @globals, @locals)
66
91
  end
67
92
 
93
+ # Builds and returns the HTTPI::Request that would be sent for this
94
+ # operation, without executing it. Useful for inspection and debugging.
95
+ # Not supported with transport: :faraday.
68
96
  def request(locals = {}, &block)
97
+ if @globals[:transport] == :faraday
98
+ raise ArgumentError, "#request returns an HTTPI::Request and is not supported " \
99
+ "with transport: :faraday. Use client.faraday to configure " \
100
+ "the connection"
101
+ end
102
+
69
103
  builder = build(locals, &block)
70
- build_request(builder)
104
+ # Build the body before soap_headers - see #call.
105
+ body = builder.to_s
106
+ @transport.to_httpi_request(endpoint.to_s, soap_headers(builder), body, @locals)
71
107
  end
72
108
 
73
109
  private
74
110
 
75
- def create_response(response)
76
- Response.new(response, @globals, @locals)
77
- end
78
-
79
111
  def set_locals(locals, block)
80
112
  locals = LocalOptions.new(locals)
81
113
  BlockInterface.new(locals).evaluate(block) if block
@@ -83,65 +115,69 @@ module Savon
83
115
  @locals = locals
84
116
  end
85
117
 
86
- def call_with_logging(request)
87
- @logger.log(request) { HTTPI.post(request, @globals[:adapter]) }
118
+ # The effective options view for the current request. Built on demand
119
+ # instead of being cached, so it always reflects the current per-request
120
+ # options.
121
+ def effective_options
122
+ EffectiveOptions.new(@name, @wsdl, @globals, @locals)
88
123
  end
89
124
 
90
- def build_request(builder)
91
- @locals[:soap_action] ||= soap_action
92
- @globals[:endpoint] ||= endpoint
93
-
94
- request = SOAPRequest.new(@globals).build(
95
- :soap_action => soap_action,
96
- :cookies => @locals[:cookies],
97
- :headers => @locals[:headers]
98
- )
99
-
100
- request.url = endpoint
101
- request.body = builder.to_s
125
+ # Assembles the SOAP-level request headers for the given builder.
126
+ #
127
+ # Our responsibility regardless of transport:
128
+ # * Content-Type (SOAP 1.1 §6 / SOAP 1.2 Part 2 §7.1.4)
129
+ # * SOAPAction (SOAP 1.1 §6.1.1)
130
+ # * Multipart Content-Type (RFC 2387), MIME-Version (RFC 2045 §4), Accept-Encoding (RFC 9110 §12.5.3)
131
+ def soap_headers(builder)
132
+ headers = {}
102
133
 
103
134
  if builder.multipart
104
- request.gzip
105
- request.headers["Content-Type"] = ["multipart/related",
106
- "type=\"#{SOAP_REQUEST_TYPE[@globals[:soap_version]]}\"",
107
- "start=\"#{builder.multipart[:start]}\"",
108
- "boundary=\"#{builder.multipart[:multipart_boundary]}\""].join("; ")
109
- request.headers["MIME-Version"] = "1.0"
135
+ # RFC 2387 §3 (multipart/related) - SOAP envelope is the root body part
136
+ headers["Content-Type"] = [
137
+ "multipart/related",
138
+ "type=\"#{SOAP_REQUEST_TYPE[@globals[:soap_version]]}\"",
139
+ "start=\"#{builder.multipart[:start]}\"",
140
+ "boundary=\"#{builder.multipart[:multipart_boundary]}\""
141
+ ].join("; ")
142
+ headers["MIME-Version"] = "1.0"
143
+ headers["Accept-Encoding"] = "gzip,deflate"
144
+ else
145
+ headers["Content-Type"] = CONTENT_TYPE[@globals[:soap_version]] % @globals[:encoding]
110
146
  end
111
147
 
112
- # TODO: could HTTPI do this automatically in case the header
113
- # was not specified manually? [dh, 2013-01-04]
114
- request.headers["Content-Length"] = request.body.bytesize.to_s
148
+ action = soap_action
149
+ headers["SOAPAction"] = %("#{action}") if action
115
150
 
116
- request
151
+ headers
117
152
  end
118
153
 
154
+ # The resolved SOAPAction of the request, used for the HTTP header.
155
+ # {Savon::EffectiveOptions#soap_action} owns the precedence rules.
119
156
  def soap_action
120
- # soap_action explicitly set to something falsy
121
- return if @locals.include?(:soap_action) && !@locals[:soap_action]
122
-
123
- # get the soap_action from local options
124
- @locals[:soap_action] ||
125
- # with no local option, but a wsdl, ask it for the soap_action
126
- @wsdl.document? && @wsdl.soap_action(@name.to_sym) ||
127
- # if there is no soap_action up to this point, fallback to a simple default
128
- Gyoku.xml_tag(@name, :key_converter => @globals[:convert_request_keys_to])
157
+ effective_options.soap_action
129
158
  end
130
159
 
160
+ # The resolved endpoint URL the request is sent to.
161
+ # {Savon::EffectiveOptions#endpoint} owns the precedence rules.
131
162
  def endpoint
132
- @globals[:endpoint] || @wsdl.endpoint.tap do |url|
133
- if @globals[:host]
134
- host_url = URI.parse(@globals[:host])
135
- url.host = host_url.host
136
- url.port = host_url.port
137
- end
138
- end
163
+ effective_options.endpoint
139
164
  end
140
165
 
141
- def raise_expected_httpi_response!
142
- raise Error, "Observers need to return an HTTPI::Response to mock " \
143
- "the request or nil to execute the request."
144
- end
166
+ # Normalizes an observer return value into a Transport::Response.
167
+ #
168
+ # Accepts Transport::Response directly (current contract), wraps
169
+ # HTTPI::Response with a deprecation warning (legacy observer support),
170
+ # and raises on anything else.
171
+ def normalize_observer_response(observer_response)
172
+ return observer_response if observer_response.is_a?(Transport::Response)
173
+
174
+ if observer_response.is_a?(HTTPI::Response)
175
+ warn "Observers returning HTTPI::Response is deprecated - return Savon::Transport::Response instead.", uplevel: 1
176
+ return Transport::Response.from_httpi(observer_response)
177
+ end
145
178
 
179
+ raise Error, "Observers need to return a Savon::Transport::Response " \
180
+ "to mock the request or nil to execute the request."
181
+ end
146
182
  end
147
183
  end