savon 2.11.2 → 2.12.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ee16983ffafe63866c9a55e2f87082982882442b
4
- data.tar.gz: 031a0b620818fa9242fa6df79d716dc2d5fb0795
3
+ metadata.gz: 5f5d529eeeacffdb613697c1e822ae8f66f48405
4
+ data.tar.gz: 107dbcdf6bf87cffefafeb4216eee8e3b1a8c06f
5
5
  SHA512:
6
- metadata.gz: 32d112e59f45ec6b6f1c65f54c33030c99ae2e2c931ce476d0c62cceb7715d62772164b0f7e182ab691a8b47d8cfeb7c83140986743f3e134e91040d099905a6
7
- data.tar.gz: 0d73626cde6c3ccec91b090f0d30cb1f6031b5fdc88834e1874719f0e269ae62c5a4e9402e863a45e7fb64c176b58f6ff8bb5296a2e13d1ddd28bb1133c9af0d
6
+ metadata.gz: 49c798231744f066396510436f9eed0a272bb7f189c43395ae54a3bf3a09d012acdd3231fb03cb6d94703d83134fe50f53cfa731f62ec156f05edb2580c45988
7
+ data.tar.gz: 65dde0f62c985730aaf4d77c2414dccd04187979d3173c4419cbb6fd1c2feaa0cc79fac7a67b54b45ef66fa03e862f91b1204c76b3429d806c8ef1f06cd5ff10
@@ -5,11 +5,10 @@ before_install:
5
5
  - gem install bundler
6
6
  script: "bundle exec rake --trace"
7
7
  rvm:
8
- - 2.0.0
9
- - 2.1.8
10
8
  - 2.2.4
11
9
  - 2.3.0
12
- - jruby
10
+ - 2.4.1
11
+ - jruby-9.1.15.0
13
12
  - rbx-2
14
13
  matrix:
15
14
  allow_failures:
@@ -1,3 +1,15 @@
1
+ # 2.12.0 (2018-01-16)
2
+
3
+ * Drop support for ruby 2.1 and below.
4
+ * Fix: [#822](https://github.com/savonrb/savon/pull/822) Raise correct error when SOAP envelope only contains a string
5
+ * Fix: [#833](https://github.com/savonrb/savon/pull/833) Fixes boolean handling regression introduced in 2.11.2
6
+ * Feature: [#794](https://github.com/savonrb/savon/pull/794), add global option ssl_ciphers.
7
+ * Feature: [#753](https://github.com/savonrb/savon/pull/753) Add headers configuration to WSDLRequest#build
8
+ * Feature: [#812](https://github.com/savonrb/savon/pull/812) Allow `proxy` option to be `nil`.
9
+ * Feature: [#838](https://github.com/savonrb/savon/pull/838) Added ssl_ca_path and ssl_cert_store to globals
10
+ * Feature: [#794](https://github.com/savonrb/savon/pull/794) Add global option ssl_ciphers
11
+
12
+
1
13
  # 2.11.2 (2017-08-03)
2
14
  * Fix: [#676](https://github.com/savonrb/savon/pull/676) Fixes handling of `content!` and `attributes!`
3
15
  * Fix: [#800](https://github.com/savonrb/savon/pull/800) Fix exception calling `SOAPFault#to_s` when http.body is empty
data/README.md CHANGED
@@ -22,7 +22,7 @@ $ gem install savon
22
22
  or add it to your Gemfile like this:
23
23
 
24
24
  ```
25
- gem 'savon', '~> 2.11.1'
25
+ gem 'savon', '~> 2.12.0'
26
26
  ```
27
27
 
28
28
  ## Usage example
@@ -46,6 +46,19 @@ response.body
46
46
  For more examples, you should check out the
47
47
  [integration tests](https://github.com/savonrb/savon/tree/version2/spec/integration).
48
48
 
49
+ ## Ruby version support
50
+ * 2.12.x - MRI 2.2, 2.3, 2.4
51
+ * 2.11.x - MRI 2.0, 2.1, 2.2, and 2.3
52
+
53
+ If you are running MRI 1.8.7, try the 2.6.x branch.
54
+
55
+ ## Running tests
56
+
57
+ ```bash
58
+ $ bundle install
59
+ $ bundle exec rspec
60
+ ```
61
+
49
62
  ## FAQ
50
63
 
51
64
  * URI::InvalidURIError -- if you see this error, then it is likely that the http client you are using cannot parse the URI for your WSDL. Try `gem install httpclient` or add it to your `Gemfile`.
@@ -137,7 +137,7 @@ module Savon
137
137
 
138
138
  # Proxy server to use for all requests.
139
139
  def proxy(proxy)
140
- @options[:proxy] = proxy
140
+ @options[:proxy] = proxy unless proxy.nil?
141
141
  end
142
142
 
143
143
  # A Hash of HTTP headers.
@@ -268,6 +268,19 @@ module Savon
268
268
  @options[:ssl_ca_cert] = cert
269
269
  end
270
270
 
271
+ def ssl_ciphers(ciphers)
272
+ @options[:ssl_ciphers] = ciphers
273
+ end
274
+
275
+ # Sets the ca cert path.
276
+ def ssl_ca_cert_path(path)
277
+ @options[:ssl_ca_cert_path] = path
278
+ end
279
+
280
+ # Sets the ssl cert store.
281
+ def ssl_cert_store(store)
282
+ @options[:ssl_cert_store] = store
283
+ end
271
284
 
272
285
  # HTTP basic auth credentials.
273
286
  def basic_auth(*credentials)
@@ -9,7 +9,7 @@ module Savon
9
9
  end
10
10
 
11
11
  def to_hash(hash, path)
12
- return unless hash
12
+ return hash unless hash
13
13
  return hash.map { |value| to_hash(value, path) } if hash.is_a?(Array)
14
14
  return hash.to_s unless hash.is_a?(Hash)
15
15
 
@@ -26,13 +26,16 @@ module Savon
26
26
  def configure_ssl
27
27
  @http_request.auth.ssl.ssl_version = @globals[:ssl_version] if @globals.include? :ssl_version
28
28
  @http_request.auth.ssl.verify_mode = @globals[:ssl_verify_mode] if @globals.include? :ssl_verify_mode
29
+ @http_request.auth.ssl.ciphers = @globals[:ssl_ciphers] if @globals.include? :ssl_ciphers
29
30
 
30
31
  @http_request.auth.ssl.cert_key_file = @globals[:ssl_cert_key_file] if @globals.include? :ssl_cert_key_file
31
- @http_request.auth.ssl.cert_key = @globals[:ssl_cert_key] if @globals.include? :ssl_cert_key
32
+ @http_request.auth.ssl.cert_key = @globals[:ssl_cert_key] if @globals.include? :ssl_cert_key
32
33
  @http_request.auth.ssl.cert_file = @globals[:ssl_cert_file] if @globals.include? :ssl_cert_file
33
- @http_request.auth.ssl.cert = @globals[:ssl_cert] if @globals.include? :ssl_cert
34
+ @http_request.auth.ssl.cert = @globals[:ssl_cert] if @globals.include? :ssl_cert
34
35
  @http_request.auth.ssl.ca_cert_file = @globals[:ssl_ca_cert_file] if @globals.include? :ssl_ca_cert_file
35
- @http_request.auth.ssl.ca_cert = @globals[:ssl_ca_cert] if @globals.include? :ssl_ca_cert
36
+ @http_request.auth.ssl.ca_cert_path = @globals[:ssl_ca_cert_path] if @globals.include? :ssl_ca_cert_path
37
+ @http_request.auth.ssl.ca_cert = @globals[:ssl_ca_cert] if @globals.include? :ssl_ca_cert
38
+ @http_request.auth.ssl.cert_store = @globals[:ssl_cert_store] if @globals.include? :ssl_cert_store
36
39
 
37
40
  @http_request.auth.ssl.cert_key_password = @globals[:ssl_cert_key_password] if @globals.include? :ssl_cert_key_password
38
41
  end
@@ -55,12 +58,19 @@ module Savon
55
58
  def build
56
59
  configure_proxy
57
60
  configure_timeouts
61
+ configure_headers
58
62
  configure_ssl
59
63
  configure_auth
60
64
  configure_redirect_handling
61
65
 
62
66
  @http_request
63
67
  end
68
+
69
+ private
70
+
71
+ def configure_headers
72
+ @http_request.headers = @globals[:headers] if @globals.include? :headers
73
+ end
64
74
  end
65
75
 
66
76
  class SOAPRequest < HTTPRequest
@@ -69,7 +69,7 @@ module Savon
69
69
 
70
70
  def find(*path)
71
71
  envelope = nori.find(hash, 'Envelope')
72
- raise_invalid_response_error! unless envelope
72
+ raise_invalid_response_error! unless envelope.is_a?(Hash)
73
73
 
74
74
  nori.find(envelope, *path)
75
75
  end
@@ -1,5 +1,3 @@
1
- require "savon"
2
-
3
1
  module Savon
4
2
  class SOAPFault < Error
5
3
 
@@ -1,3 +1,3 @@
1
1
  module Savon
2
- VERSION = '2.11.2'
2
+ VERSION = '2.12.0'
3
3
  end
@@ -23,10 +23,10 @@ Gem::Specification.new do |s|
23
23
  s.add_dependency "akami", "~> 1.2"
24
24
  s.add_dependency "gyoku", "~> 1.2"
25
25
  s.add_dependency "builder", ">= 2.1.2"
26
- s.add_dependency "nokogiri", ">= 1.4.0"
26
+ s.add_dependency "nokogiri", ">= 1.8.1"
27
27
 
28
28
  s.add_development_dependency "rack"
29
- s.add_development_dependency "puma", "2.0.0.b4"
29
+ s.add_development_dependency "puma", "~> 3.0"
30
30
 
31
31
  s.add_development_dependency "rake", "~> 10.1"
32
32
  s.add_development_dependency "rspec", "~> 2.14"
@@ -0,0 +1 @@
1
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">Text</soap:Envelope>
@@ -2,14 +2,16 @@ require 'spec_helper'
2
2
 
3
3
  module LogInterceptor
4
4
  @@intercepted_request = ""
5
- def self.debug(message)
5
+ def self.debug(message = nil)
6
+ message ||= yield if block_given?
7
+
6
8
  # save only the first XMLly message
7
9
  if message.include? "xml version"
8
10
  @@intercepted_request = message if @@intercepted_request == ""
9
11
  end
10
12
  end
11
13
 
12
- def self.info(message)
14
+ def self.info(message = nil)
13
15
  end
14
16
 
15
17
  def self.get_intercepted_request
@@ -27,6 +29,7 @@ describe 'Correct translation of attributes to XML' do
27
29
 
28
30
  client = Savon.client(
29
31
  :wsdl => "http://mt205.sabameeting.com/CWS/CWS.asmx?WSDL",
32
+ :log => true,
30
33
  :logger => LogInterceptor
31
34
  )
32
35
 
@@ -48,14 +51,12 @@ describe 'Correct translation of attributes to XML' do
48
51
 
49
52
  client = Savon.client(
50
53
  :wsdl => "http://mt205.sabameeting.com/CWS/CWS.asmx?WSDL",
54
+ :log => true,
51
55
  :logger => LogInterceptor
52
56
  )
53
57
 
54
58
  response = nil
55
- begin
56
- response = call_and_fail_gracefully(client, :add_new_user, :message => { :user => {}, :attributes! => { :user => { :userID => "test" } } })
57
- rescue
58
- end
59
+ response = call_and_fail_gracefully(client, :add_new_user, :message => { :user => {}, :attributes! => { :user => { :userID => "test" } } })
59
60
 
60
61
  xml_doc = Nokogiri::XML(LogInterceptor.get_intercepted_request)
61
62
  xml_doc.remove_namespaces!
@@ -1,4 +1,4 @@
1
- require "rack/builder"
1
+ require "rack"
2
2
  require "json"
3
3
 
4
4
  class IntegrationServer
@@ -389,18 +389,27 @@ describe "Options" do
389
389
 
390
390
  context "global :ssl_version" do
391
391
  it "sets the SSL version to use" do
392
- HTTPI::Auth::SSL.any_instance.expects(:ssl_version=).with(:SSLv3).twice
392
+ HTTPI::Auth::SSL.any_instance.expects(:ssl_version=).with(:TLSv1).twice
393
393
 
394
- client = new_client(:endpoint => @server.url, :ssl_version => :SSLv3)
394
+ client = new_client(:endpoint => @server.url, :ssl_version => :TLSv1)
395
395
  client.call(:authenticate)
396
396
  end
397
397
  end
398
398
 
399
399
  context "global :ssl_verify_mode" do
400
400
  it "sets the verify mode to use" do
401
- HTTPI::Auth::SSL.any_instance.expects(:verify_mode=).with(:none).twice
401
+ HTTPI::Auth::SSL.any_instance.expects(:verify_mode=).with(:peer).twice
402
402
 
403
- client = new_client(:endpoint => @server.url, :ssl_verify_mode => :none)
403
+ client = new_client(:endpoint => @server.url, :ssl_verify_mode => :peer)
404
+ client.call(:authenticate)
405
+ end
406
+ end
407
+
408
+ context "global :ssl_ciphers" do
409
+ it "sets the ciphers to use" do
410
+ HTTPI::Auth::SSL.any_instance.expects(:ciphers=).with(:none).twice
411
+
412
+ client = new_client(:endpoint => @server.url, :ssl_ciphers => :none)
404
413
  client.call(:authenticate)
405
414
  end
406
415
  end
@@ -469,6 +478,26 @@ describe "Options" do
469
478
  end
470
479
  end
471
480
 
481
+ context "global :ssl_ca_cert_path" do
482
+ it "sets the ca cert path to use" do
483
+ ca_cert_path = "../../fixtures/ssl"
484
+ HTTPI::Auth::SSL.any_instance.expects(:ca_cert_path=).with(ca_cert_path).twice
485
+
486
+ client = new_client(:endpoint => @server.url, :ssl_ca_cert_path => ca_cert_path)
487
+ client.call(:authenticate)
488
+ end
489
+ end
490
+
491
+ context "global :ssl_ca_cert_store" do
492
+ it "sets the cert store to use" do
493
+ cert_store = OpenSSL::X509::Store.new
494
+ HTTPI::Auth::SSL.any_instance.expects(:cert_store=).with(cert_store).twice
495
+
496
+ client = new_client(:endpoint => @server.url, :ssl_cert_store => cert_store)
497
+ client.call(:authenticate)
498
+ end
499
+ end
500
+
472
501
  context "global :ssl_ca_cert" do
473
502
  it "sets the ca cert file to use" do
474
503
  ca_cert = File.open(File.expand_path("../../fixtures/ssl/client_cert.pem", __FILE__)).read
@@ -62,6 +62,39 @@ module Savon
62
62
  expect(resulting_hash).to eq good_result
63
63
  expect(xml).to eq good_xml
64
64
  end
65
+
66
+ it "properly handles boolean false" do
67
+ used_namespaces = {
68
+ %w(tns Foo) => 'ns'
69
+ }
70
+
71
+ hash = {
72
+ :foo => {
73
+ :falsey => {
74
+ :@attr1 => false,
75
+ :content! => false
76
+ }
77
+ }
78
+ }
79
+
80
+ good_result = {
81
+ "ns:Foo" => {
82
+ :falsey => {
83
+ :@attr1 => false,
84
+ :content! => false
85
+ }
86
+ }
87
+ }
88
+
89
+ good_xml = %(<ns:Foo><Falsey attr1="false">false</Falsey></ns:Foo>)
90
+
91
+ message = described_class.new(types, used_namespaces, key_converter)
92
+ resulting_hash = message.to_hash(hash, ['tns'])
93
+ xml = Gyoku.xml(resulting_hash, key_converter: key_converter)
94
+
95
+ expect(resulting_hash).to eq good_result
96
+ expect(xml).to eq good_xml
97
+ end
65
98
  end
66
99
 
67
100
  end
@@ -5,6 +5,7 @@ describe Savon::WSDLRequest do
5
5
 
6
6
  let(:globals) { Savon::GlobalOptions.new }
7
7
  let(:http_request) { HTTPI::Request.new }
8
+ let(:ciphers) { OpenSSL::Cipher.ciphers }
8
9
 
9
10
  def new_wsdl_request
10
11
  Savon::WSDLRequest.new(globals, http_request)
@@ -16,6 +17,20 @@ describe Savon::WSDLRequest do
16
17
  expect(wsdl_request.build).to be_an(HTTPI::Request)
17
18
  end
18
19
 
20
+ describe "headers" do
21
+ it "are set when specified" do
22
+ globals.headers("Proxy-Authorization" => "Basic auth")
23
+ configured_http_request = new_wsdl_request.build
24
+
25
+ expect(configured_http_request.headers["Proxy-Authorization"]).to eq("Basic auth")
26
+ end
27
+
28
+ it "are not set otherwise" do
29
+ configured_http_request = new_wsdl_request.build
30
+ expect(configured_http_request.headers).to_not include("Proxy-Authorization")
31
+ end
32
+ end
33
+
19
34
  describe "proxy" do
20
35
  it "is set when specified" do
21
36
  globals.proxy("http://proxy.example.com")
@@ -60,8 +75,8 @@ describe Savon::WSDLRequest do
60
75
 
61
76
  describe "ssl version" do
62
77
  it "is set when specified" do
63
- globals.ssl_version(:SSLv3)
64
- http_request.auth.ssl.expects(:ssl_version=).with(:SSLv3)
78
+ globals.ssl_version(:TLSv1)
79
+ http_request.auth.ssl.expects(:ssl_version=).with(:TLSv1)
65
80
 
66
81
  new_wsdl_request.build
67
82
  end
@@ -74,8 +89,8 @@ describe Savon::WSDLRequest do
74
89
 
75
90
  describe "ssl verify mode" do
76
91
  it "is set when specified" do
77
- globals.ssl_verify_mode(:none)
78
- http_request.auth.ssl.expects(:verify_mode=).with(:none)
92
+ globals.ssl_verify_mode(:peer)
93
+ http_request.auth.ssl.expects(:verify_mode=).with(:peer)
79
94
 
80
95
  new_wsdl_request.build
81
96
  end
@@ -86,6 +101,20 @@ describe Savon::WSDLRequest do
86
101
  end
87
102
  end
88
103
 
104
+ describe "ssl ciphers" do
105
+ it "is set when specified" do
106
+ globals.ssl_ciphers(ciphers)
107
+ http_request.auth.ssl.expects(:ciphers=).with(ciphers)
108
+
109
+ new_wsdl_request.build
110
+ end
111
+
112
+ it "is not set otherwise" do
113
+ http_request.auth.ssl.expects(:ciphers=).never
114
+ new_wsdl_request.build
115
+ end
116
+ end
117
+
89
118
  describe "ssl cert key file" do
90
119
  it "is set when specified" do
91
120
  cert_key = File.expand_path("../../fixtures/ssl/client_key.pem", __FILE__)
@@ -232,6 +261,7 @@ describe Savon::SOAPRequest do
232
261
 
233
262
  let(:globals) { Savon::GlobalOptions.new }
234
263
  let(:http_request) { HTTPI::Request.new }
264
+ let(:ciphers) { OpenSSL::Cipher.ciphers }
235
265
 
236
266
  def new_soap_request
237
267
  Savon::SOAPRequest.new(globals, http_request)
@@ -364,8 +394,8 @@ describe Savon::SOAPRequest do
364
394
 
365
395
  describe "ssl version" do
366
396
  it "is set when specified" do
367
- globals.ssl_version(:SSLv3)
368
- http_request.auth.ssl.expects(:ssl_version=).with(:SSLv3)
397
+ globals.ssl_version(:TLSv1)
398
+ http_request.auth.ssl.expects(:ssl_version=).with(:TLSv1)
369
399
 
370
400
  new_soap_request.build
371
401
  end
@@ -378,8 +408,8 @@ describe Savon::SOAPRequest do
378
408
 
379
409
  describe "ssl verify mode" do
380
410
  it "is set when specified" do
381
- globals.ssl_verify_mode(:none)
382
- http_request.auth.ssl.expects(:verify_mode=).with(:none)
411
+ globals.ssl_verify_mode(:peer)
412
+ http_request.auth.ssl.expects(:verify_mode=).with(:peer)
383
413
 
384
414
  new_soap_request.build
385
415
  end
@@ -390,6 +420,20 @@ describe Savon::SOAPRequest do
390
420
  end
391
421
  end
392
422
 
423
+ describe "ssl ciphers" do
424
+ it "is set when specified" do
425
+ globals.ssl_ciphers(ciphers)
426
+ http_request.auth.ssl.expects(:ciphers=).with(ciphers)
427
+
428
+ new_soap_request.build
429
+ end
430
+
431
+ it "is not set otherwise" do
432
+ http_request.auth.ssl.expects(:ciphers=).never
433
+ new_soap_request.build
434
+ end
435
+ end
436
+
393
437
  describe "ssl cert key file" do
394
438
  it "is set when specified" do
395
439
  cert_key = File.expand_path("../../fixtures/ssl/client_key.pem", __FILE__)
@@ -235,6 +235,11 @@ describe Savon::Response do
235
235
  expect(result).to be_a(Hash)
236
236
  expect(result.keys).to include(:authentication_value)
237
237
  end
238
+
239
+ it 'fails correctly when envelope contains only string' do
240
+ response = soap_response({ :body => Fixture.response(:no_body) })
241
+ expect { response.find('Body') }.to raise_error Savon::InvalidResponseError
242
+ end
238
243
  end
239
244
 
240
245
  describe "#http" do
@@ -3,7 +3,7 @@ module SpecSupport
3
3
  def call_and_fail_gracefully(client, *args, &block)
4
4
  client.call(*args, &block)
5
5
  rescue Savon::SOAPFault => e
6
- pending e.message
6
+ puts e.message
7
7
  end
8
8
 
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: savon
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.11.2
4
+ version: 2.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Harrington
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-08-03 00:00:00.000000000 Z
11
+ date: 2018-01-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nori
@@ -100,14 +100,14 @@ dependencies:
100
100
  requirements:
101
101
  - - ">="
102
102
  - !ruby/object:Gem::Version
103
- version: 1.4.0
103
+ version: 1.8.1
104
104
  type: :runtime
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - ">="
109
109
  - !ruby/object:Gem::Version
110
- version: 1.4.0
110
+ version: 1.8.1
111
111
  - !ruby/object:Gem::Dependency
112
112
  name: rack
113
113
  requirement: !ruby/object:Gem::Requirement
@@ -126,16 +126,16 @@ dependencies:
126
126
  name: puma
127
127
  requirement: !ruby/object:Gem::Requirement
128
128
  requirements:
129
- - - '='
129
+ - - "~>"
130
130
  - !ruby/object:Gem::Version
131
- version: 2.0.0.b4
131
+ version: '3.0'
132
132
  type: :development
133
133
  prerelease: false
134
134
  version_requirements: !ruby/object:Gem::Requirement
135
135
  requirements:
136
- - - '='
136
+ - - "~>"
137
137
  - !ruby/object:Gem::Version
138
- version: 2.0.0.b4
138
+ version: '3.0'
139
139
  - !ruby/object:Gem::Dependency
140
140
  name: rake
141
141
  requirement: !ruby/object:Gem::Requirement
@@ -237,6 +237,7 @@ files:
237
237
  - spec/fixtures/response/header.xml
238
238
  - spec/fixtures/response/list.xml
239
239
  - spec/fixtures/response/multi_ref.xml
240
+ - spec/fixtures/response/no_body.xml
240
241
  - spec/fixtures/response/soap_fault.xml
241
242
  - spec/fixtures/response/soap_fault12.xml
242
243
  - spec/fixtures/response/soap_fault_funky.xml
@@ -311,7 +312,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
311
312
  version: '0'
312
313
  requirements: []
313
314
  rubyforge_project: savon
314
- rubygems_version: 2.6.12
315
+ rubygems_version: 2.6.13
315
316
  signing_key:
316
317
  specification_version: 4
317
318
  summary: Heavy metal SOAP client