savon 2.17.5 → 2.18.0
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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +21 -0
- data/lib/savon/builder.rb +59 -11
- data/lib/savon/effective_options.rb +21 -6
- data/lib/savon/log_message.rb +51 -2
- data/lib/savon/message.rb +13 -0
- data/lib/savon/model.rb +13 -2
- data/lib/savon/operation.rb +10 -2
- data/lib/savon/options.rb +17 -2
- data/lib/savon/qualified_message.rb +89 -9
- data/lib/savon/transport/logging.rb +8 -1
- data/lib/savon/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b824b46b6baa9ec754839a85d8f4225b62ba562b4d060143c6b02cd41e4880f8
|
|
4
|
+
data.tar.gz: fbaf6f7ed2607e3c24d356c6ffb282f089b1426fc89b8fdcde5c01918c657e9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: efd307c9e7dffa95ddccedf76b3bc675f27c3974d8742722ff2ebfb50b173ca687dd83a6a91fd97f637a56e5057e928d0dfa9adbcdfb0efb6c5c639215ba620f
|
|
7
|
+
data.tar.gz: 5170029557b974cb66c5adb409d339c3a2d044ee0427aca330e275878afbfbaf59d0d4f5c51bdd63fd9a68d1c4ec8720e617ac3ce09ad64cb59645d2872a948e
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.18.0] - 2026-11-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* **`order!: :use_schema` orders a message like the WSDL's `xs:sequence`** ([#592](https://github.com/savonrb/savon/issues/592)). Nested hashes and arrays of hashes follow, and an explicit `order!` array on a nested hash still wins, whether the schema qualifies its elements or not. A level the schema doesn't describe, or a key it doesn't list (elements inside an `xs:choice` or `xs:group` included, which Wasabi doesn't record), raises `Savon::Error` instead of guessing the order: an explicit `order!` on that level works around it.
|
|
13
|
+
|
|
14
|
+
* **`mtom: true` emits outbound MTOM/XOP request framing** ([#1047](https://github.com/savonrb/savon/pull/1047)). With `attachments`, the request becomes `multipart/related` with an `application/xop+xml` SOAP root part. This is low-level framing only: callers provide the `xop:Include` XML and matching attachment Content-IDs. Savon does not rewrite normal base64 XML into XOP, generate `xop:Include` nodes, validate those references, infer MIME types from the XML, or deserialize inbound MTOM responses. An attachment hash may set `content_id:` to choose its Content-ID independently of its filename, e.g. when the filename holds characters invalid in a header or two attachments share a filename. A Content-ID that is not ASCII or holds spaces or header punctuation raises `ArgumentError` when it was chosen explicitly or the request is MTOM; for plain attachments with a default filename it warns instead. Log output keeps non-Latin text in multipart bodies even next to binary attachments, and filters and pretty-printing now apply to the XML part of a multipart request instead of stopping at its MIME headers.
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
* `Savon::Model.operations` and `.all_operations` no longer rely on a hand-maintained list of reserved names. The guard now derives its denylist from the model's own ancestor chains — every method instances respond to plus every method the class object responds to — so a WSDL operation can never shadow an existing method. This closes the `class`/`public_send` bypass (unbounded recursion via the generated `self.class.public_send(...)` bodies, hijacked reflective dispatch) and the whole class of follow-on reports (`new`, `freeze`, `send`, …) by construction instead of one advisory at a time. Re-registering an already-defined operation is still allowed; anything else that collides raises `ArgumentError`. Call such operations explicitly through `model.client.call(:operation, ...)`.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
* `Builder` no longer reuses a reserved `xml`-prefixed identifier from the WSDL namespace map. When a schema's `targetNamespace` matched the WSDL root's default namespace, requests declared `xmlns:xmlns` and used elements like `<xmlns:Op>`, so the envelope was not namespace-well-formed. Such namespaces now get another prefix the WSDL declares for the same URI, or `ins0`, `ins1`, … if there is none.
|
|
23
|
+
|
|
24
|
+
* Elements whose types live in another namespace now keep their own prefix instead of `tns:` ([#895](https://github.com/savonrb/savon/issues/895)). Strict services rejected those messages. The type lookup missed because message keys pass through the key converter first (`:entity_ids` becomes `entityIds` while the schema defines `EntityIds`), so every element fell back to `tns:`. The lookup now tries a case-insensitive match when an exact match misses.
|
|
25
|
+
|
|
26
|
+
* A document with a WSSE signature is built three times instead of twice, which signed timestamps need ([#1079](https://github.com/savonrb/savon/pull/1079)). See [akami#25](https://github.com/savonrb/akami/pull/25).
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
* A URL assigned directly to `client.wsdl.endpoint` is overridden by per-operation port routing once wasabi 5.2 ships ([#879](https://github.com/savonrb/savon/issues/879)). `EffectiveOptions#endpoint` now asks the WSDL document for the current operation's port endpoint first and only falls back to the document-wide address. Pass `:endpoint` explicitly instead — that is still checked first and wins.
|
|
31
|
+
|
|
32
|
+
### Performance
|
|
33
|
+
|
|
34
|
+
* `Builder` looks up the namespace identifier of each WSDL type path with `Hash#key` instead of a `gsub` per candidate prefix: requests built from a 110 KB WSDL allocate 21% fewer objects and 42% less memory.
|
|
35
|
+
|
|
8
36
|
## [2.17.5] - 2026-09-09
|
|
9
37
|
|
|
10
38
|
### Security
|
data/README.md
CHANGED
|
@@ -66,6 +66,25 @@ See [Authentication](https://savonrb.com/version2/globals.html) on the website f
|
|
|
66
66
|
|
|
67
67
|
Savon uses [HTTPI](https://github.com/savonrb/httpi) for HTTP by default. Since v2.17.0 you can opt-in to use Faraday by passing `transport: :faraday` which exposes the Faraday connection at `client.faraday` for you to configure. Savon is only adding some SOAP-specifics on top.
|
|
68
68
|
|
|
69
|
+
### MTOM/XOP attachments
|
|
70
|
+
|
|
71
|
+
To send a binary file with MTOM, pass a pre-built envelope with an `xop:Include` reference, set `mtom: true`, and give the matching attachment. The Content-ID must match the `cid:` href; set `content_id:` on the attachment when it differs from the filename:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
client.call(:upload,
|
|
75
|
+
xml: <<~XML,
|
|
76
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
|
|
77
|
+
xmlns:xop="http://www.w3.org/2004/08/xop/include">
|
|
78
|
+
<soap:Body>
|
|
79
|
+
<tns:upload><tns:file><xop:Include href="cid:doc1@example"/></tns:file></tns:upload>
|
|
80
|
+
</soap:Body>
|
|
81
|
+
</soap:Envelope>
|
|
82
|
+
XML
|
|
83
|
+
mtom: true,
|
|
84
|
+
attachments: [{ filename: "report.pdf", content_id: "doc1@example", content: File.binread("report.pdf") }]
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
69
88
|
## Ruby support
|
|
70
89
|
|
|
71
90
|
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.
|
|
@@ -84,6 +103,8 @@ Callers on 2.13.0–2.15.x may see specific post-2.12.1 behaviors restored to th
|
|
|
84
103
|
|
|
85
104
|
**WSDL imports are not followed.** Savon parses only the root WSDL document via [Wasabi](https://github.com/savonrb/wasabi). Messages, port types, and bindings defined in imported files are invisible to Savon. If you control the WSDL, merge the imported elements into the root document and pass that to Savon as a string. If you need full import support, the [WSDL](https://rubygems.org/gems/wsdl) gem is an alternative.
|
|
86
105
|
|
|
106
|
+
**MTOM/XOP support is request framing only.** Savon can emit outbound MTOM/XOP request framing, but it does not rewrite normal base64 XML into XOP, generate `xop:Include` nodes, validate references, infer MIME types from XML, or deserialize inbound MTOM responses. Callers provide the `xop:Include` XML and matching attachment Content-IDs. `mtom: true` without `attachments` has no effect. For SOAP 1.2, the action is still sent as a `SOAPAction` header, as elsewhere in Savon, rather than as the `action=` parameter of the start-info media type.
|
|
107
|
+
|
|
87
108
|
## Contributing
|
|
88
109
|
|
|
89
110
|
See [CONTRIBUTING.md](CONTRIBUTING.md). Everyone interacting in Savon's codebases, issue trackers, and discussion forums is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md). MIT licensed.
|
data/lib/savon/builder.rb
CHANGED
|
@@ -30,6 +30,11 @@ module Savon
|
|
|
30
30
|
2 => "http://www.w3.org/2003/05/soap-envelope"
|
|
31
31
|
}.freeze
|
|
32
32
|
|
|
33
|
+
SOAP_XOP_REQUEST_TYPE = {
|
|
34
|
+
1 => "text/xml",
|
|
35
|
+
2 => "application/soap+xml"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
33
38
|
# @deprecated Use {Savon::Addressing::NAMESPACE} instead. This alias will
|
|
34
39
|
# be removed in Savon 3.0.
|
|
35
40
|
WSA_NAMESPACE = Addressing::NAMESPACE
|
|
@@ -63,7 +68,7 @@ module Savon
|
|
|
63
68
|
if @signature
|
|
64
69
|
@signature.document = xml_result
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
3.times do
|
|
67
72
|
@header = nil
|
|
68
73
|
@signature.document = build_xml
|
|
69
74
|
end
|
|
@@ -124,7 +129,7 @@ module Savon
|
|
|
124
129
|
@internal_namespace_count ||= 0
|
|
125
130
|
|
|
126
131
|
unless (identifier = namespace_by_uri(uri))
|
|
127
|
-
wsdl_identifier =
|
|
132
|
+
wsdl_identifier = wsdl_namespace_identifier(uri)
|
|
128
133
|
# The prefix may already be taken by the target namespace or a user-supplied
|
|
129
134
|
# :namespaces override - fall back to ins0, ins1... rather than overwriting it.
|
|
130
135
|
wsdl_identifier = nil if wsdl_identifier && namespaces.key?("xmlns:#{wsdl_identifier}")
|
|
@@ -136,6 +141,12 @@ module Savon
|
|
|
136
141
|
[path, identifier]
|
|
137
142
|
end
|
|
138
143
|
|
|
144
|
+
def wsdl_namespace_identifier(uri)
|
|
145
|
+
return unless @wsdl.document?
|
|
146
|
+
|
|
147
|
+
@wsdl.parser.namespaces.find { |key, value| value == uri && !key.downcase.start_with?("xml") }&.first
|
|
148
|
+
end
|
|
149
|
+
|
|
139
150
|
def namespaces_with_globals
|
|
140
151
|
namespaces.merge @globals[:namespaces]
|
|
141
152
|
end
|
|
@@ -188,7 +199,7 @@ module Savon
|
|
|
188
199
|
break if @locals[:message].nil?
|
|
189
200
|
|
|
190
201
|
message_locals = @locals[:message][StringUtils.snakecase(message).to_sym]
|
|
191
|
-
message_content = Message.new(
|
|
202
|
+
message_content = Message.new(type.last, namespace_identifier, @types, @used_namespaces, message_locals, :unqualified, @globals[:convert_request_keys_to], @globals[:unwrap]).to_s
|
|
192
203
|
messages += "<#{message} xsi:type=\"#{type.join(':')}\">#{message_content}</#{message}>"
|
|
193
204
|
end
|
|
194
205
|
messages
|
|
@@ -236,10 +247,7 @@ module Savon
|
|
|
236
247
|
end
|
|
237
248
|
|
|
238
249
|
def namespace_by_uri(uri)
|
|
239
|
-
namespaces.
|
|
240
|
-
return candidate_identifier.gsub(/^xmlns:/, '') if candidate_uri == uri
|
|
241
|
-
end
|
|
242
|
-
nil
|
|
250
|
+
namespaces.key(uri)&.delete_prefix("xmlns:")
|
|
243
251
|
end
|
|
244
252
|
|
|
245
253
|
def builder
|
|
@@ -277,15 +285,31 @@ module Savon
|
|
|
277
285
|
|
|
278
286
|
# the mail.body.encoded algorithm reorders the parts, default order is [ "text/plain", "text/enriched", "text/html" ]
|
|
279
287
|
# should redefine the sort order, because the soap request xml should be the first
|
|
280
|
-
multipart_message.body.set_sort_order
|
|
288
|
+
multipart_message.body.set_sort_order(
|
|
289
|
+
@locals[:mtom] ? ["application/xop+xml", "text/xml"] : ["text/xml"]
|
|
290
|
+
)
|
|
281
291
|
|
|
282
292
|
multipart_message.body.encoded(multipart_message.content_transfer_encoding)
|
|
283
293
|
end
|
|
284
294
|
|
|
285
295
|
def init_multipart_message(message_xml)
|
|
286
296
|
multipart_message = Mail.new
|
|
297
|
+
|
|
298
|
+
# MTOM differs from general SOAP attachments:
|
|
299
|
+
# 1. binary encoding
|
|
300
|
+
# 2. application/xop+xml mime type
|
|
301
|
+
if @locals[:mtom]
|
|
302
|
+
type = "application/xop+xml; charset=#{@globals[:encoding]}; " \
|
|
303
|
+
"type=\"#{SOAP_XOP_REQUEST_TYPE[@globals[:soap_version]]}\""
|
|
304
|
+
|
|
305
|
+
multipart_message.transport_encoding = 'binary'
|
|
306
|
+
message_xml = message_xml.dup.force_encoding('BINARY')
|
|
307
|
+
else
|
|
308
|
+
type = 'text/xml'
|
|
309
|
+
end
|
|
310
|
+
|
|
287
311
|
xml_part = Mail::Part.new do
|
|
288
|
-
content_type
|
|
312
|
+
content_type type
|
|
289
313
|
body message_xml
|
|
290
314
|
# in Content-Type the start parameter is recommended (RFC 2387)
|
|
291
315
|
content_id '<soap-request-body@soap>'
|
|
@@ -305,19 +329,43 @@ module Savon
|
|
|
305
329
|
if @locals[:attachments].is_a? Hash
|
|
306
330
|
# hash example: { 'att1' => '/path/to/att1', 'att2' => '/path/to/att2' }
|
|
307
331
|
@locals[:attachments].each do |identifier, attachment|
|
|
332
|
+
explicit = attachment.is_a?(Hash) && attachment.key?(:content_id)
|
|
333
|
+
identifier = attachment[:content_id] if explicit
|
|
334
|
+
validate_content_id!(identifier, explicit: explicit)
|
|
308
335
|
add_attachment_to_multipart_message(multipart_message, attachment, identifier)
|
|
309
336
|
end
|
|
310
337
|
elsif @locals[:attachments].is_a? Array
|
|
311
338
|
# array example: [ '/path/to/att1', '/path/to/att2' ]
|
|
312
339
|
# array example: [ { filename: 'att1.xml', content: '<x/>' }, { filename: 'att2.xml', content: '<y/>' } ]
|
|
313
340
|
@locals[:attachments].each do |attachment|
|
|
314
|
-
|
|
341
|
+
explicit = attachment.is_a?(Hash) && attachment.key?(:content_id)
|
|
342
|
+
identifier = attachment.is_a?(String) ? File.basename(attachment) : (attachment[:content_id] || attachment[:filename])
|
|
343
|
+
validate_content_id!(identifier, explicit: explicit)
|
|
344
|
+
add_attachment_to_multipart_message(multipart_message, attachment, identifier)
|
|
315
345
|
end
|
|
316
346
|
end
|
|
317
347
|
end
|
|
318
348
|
|
|
349
|
+
# Content-IDs are matched against xop:Include hrefs, and the mail library cuts the header at the first
|
|
350
|
+
# space, so an ID with non-ASCII characters, spaces or header punctuation silently stops matching.
|
|
351
|
+
# Fail fast when the caller chose the ID or the request is MTOM; for plain attachments, whose default
|
|
352
|
+
# filename IDs predate this check, warn instead so current users do not break.
|
|
353
|
+
def validate_content_id!(identifier, explicit:)
|
|
354
|
+
id = identifier.to_s
|
|
355
|
+
return if !id.empty? && id.ascii_only? && id !~ /[\s()<>,;:"\[\]]/
|
|
356
|
+
|
|
357
|
+
message = "Invalid Content-ID #{id.inspect}: use ASCII without spaces or ()<>,;:\"[]. " \
|
|
358
|
+
"Set content_id: on the attachment to choose a valid ID."
|
|
359
|
+
raise ArgumentError, message if explicit || @locals[:mtom]
|
|
360
|
+
|
|
361
|
+
warn "#{message} The filename is used as-is.", uplevel: 1
|
|
362
|
+
end
|
|
363
|
+
|
|
319
364
|
def add_attachment_to_multipart_message(multipart_message, attachment, identifier)
|
|
320
|
-
|
|
365
|
+
# :content_id only selects the Content-ID header; the mail part itself
|
|
366
|
+
# takes its filename from :filename, so it must not reach add_file.
|
|
367
|
+
file = attachment.is_a?(Hash) ? attachment.reject { |key, _| key == :content_id } : attachment
|
|
368
|
+
multipart_message.add_file file.clone
|
|
321
369
|
multipart_message.parts.last.content_id = multipart_message.parts.last.content_location = identifier.to_s
|
|
322
370
|
end
|
|
323
371
|
end
|
|
@@ -45,18 +45,23 @@ module Savon
|
|
|
45
45
|
|
|
46
46
|
# Resolves the endpoint URL the request is sent to.
|
|
47
47
|
#
|
|
48
|
-
# A global +:endpoint+ wins over the service address of the WSDL.
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
48
|
+
# A global +:endpoint+ wins over the service address of the WSDL. When
|
|
49
|
+
# the WSDL document exposes per-port endpoints (wasabi >= 5.2), the
|
|
50
|
+
# address of the port serving the current operation wins over the
|
|
51
|
+
# document-wide address. The global +:host+ option replaces host and
|
|
52
|
+
# port of the winning address and keeps scheme, path and query. The
|
|
53
|
+
# override is applied to a copy. The WSDL document keeps its parsed
|
|
54
|
+
# address.
|
|
52
55
|
#
|
|
53
56
|
# @return [URI, String, nil] the endpoint as provided by the winning source
|
|
54
57
|
def endpoint
|
|
55
58
|
return @globals[:endpoint] if @globals[:endpoint]
|
|
56
|
-
|
|
59
|
+
|
|
60
|
+
url = operation_endpoint || @wsdl.endpoint
|
|
61
|
+
return url unless @globals[:host]
|
|
57
62
|
|
|
58
63
|
host_url = URI.parse(@globals[:host])
|
|
59
|
-
url =
|
|
64
|
+
url = url.dup
|
|
60
65
|
url.host = host_url.host
|
|
61
66
|
url.port = host_url.port
|
|
62
67
|
url
|
|
@@ -111,6 +116,16 @@ module Savon
|
|
|
111
116
|
|
|
112
117
|
private
|
|
113
118
|
|
|
119
|
+
# Resolves the endpoint of the port serving the current operation.
|
|
120
|
+
# Returns +nil+ when the WSDL document has no per-port endpoint API
|
|
121
|
+
# (wasabi < 5.2) or when no port serves the operation, so the caller
|
|
122
|
+
# falls back to the document-wide endpoint.
|
|
123
|
+
def operation_endpoint
|
|
124
|
+
return unless @wsdl.document? && @wsdl.respond_to?(:endpoint_for_operation)
|
|
125
|
+
|
|
126
|
+
@wsdl.endpoint_for_operation(@operation_name, soap_version: @globals[:soap_version])
|
|
127
|
+
end
|
|
128
|
+
|
|
114
129
|
# Resolves an option where a local +false+ is meaningful and disables it.
|
|
115
130
|
# Only a local +nil+ falls through to the global value. Contrast with
|
|
116
131
|
# {#wsse_signature}, which treats any falsy local as unset.
|
data/lib/savon/log_message.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "nokogiri"
|
|
|
4
4
|
|
|
5
5
|
module Savon
|
|
6
6
|
class LogMessage
|
|
7
|
+
MIME_HEADER_PREFIX = /\A\s*[A-Za-z0-9-]+:/
|
|
8
|
+
|
|
7
9
|
def initialize(message, filters = [], pretty_print = false)
|
|
8
10
|
@message = message
|
|
9
11
|
@filters = filters
|
|
@@ -11,20 +13,67 @@ module Savon
|
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def to_s
|
|
14
|
-
|
|
16
|
+
message = @message
|
|
17
|
+
begin
|
|
18
|
+
message_is_xml = xml_start(message)
|
|
19
|
+
rescue ArgumentError
|
|
20
|
+
# The message encoding holds invalid bytes (e.g. a binary multipart
|
|
21
|
+
# body mistagged as UTF-8): search the bytes instead of raising.
|
|
22
|
+
message = message.b
|
|
23
|
+
message_is_xml = xml_start(message)
|
|
24
|
+
end
|
|
15
25
|
has_filters = @filters.any?
|
|
16
26
|
pretty_print = @pretty_print
|
|
17
27
|
|
|
18
28
|
return @message unless message_is_xml
|
|
19
29
|
return @message unless has_filters || pretty_print
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
# The message may be multipart, cut off extra lines before open tag
|
|
32
|
+
# and stop at the next MIME boundary, so the attachments that follow the
|
|
33
|
+
# XML part do not make the document invalid.
|
|
34
|
+
xml_end = message.index(/\r?\n--/, message_is_xml) || message.length
|
|
35
|
+
document = Nokogiri.XML(message[message_is_xml...xml_end])
|
|
22
36
|
document = apply_filter(document) if has_filters
|
|
23
37
|
document.to_xml(nokogiri_options)
|
|
24
38
|
end
|
|
25
39
|
|
|
26
40
|
private
|
|
27
41
|
|
|
42
|
+
# The index of the XML document in the message. Multipart messages have MIME
|
|
43
|
+
# headers before the XML part, and these can contain '<' (Content-ID: <...>).
|
|
44
|
+
# If the first '<' is on a header line, the document starts at the first '<'
|
|
45
|
+
# after the next blank line. A '<' before any blank line that is not on a
|
|
46
|
+
# header line belongs to the document itself (e.g. a declaration-less
|
|
47
|
+
# envelope with blank lines inside it).
|
|
48
|
+
#
|
|
49
|
+
# A fixed set of searches, plus a loop that only advances forward past
|
|
50
|
+
# nested header tags, so the search stays linear in the message size.
|
|
51
|
+
# Log input is attacker-influenced on the response side, so logging must
|
|
52
|
+
# never be a slow path.
|
|
53
|
+
def xml_start(message)
|
|
54
|
+
message.index("<?xml") || first_tag(message)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def first_tag(message)
|
|
58
|
+
first = message.index("<")
|
|
59
|
+
return first unless first && mime_header_line?(message, first)
|
|
60
|
+
|
|
61
|
+
pos = first
|
|
62
|
+
loop do
|
|
63
|
+
blank_line = message.index(/\r?\n\r?\n/, pos)
|
|
64
|
+
tag = blank_line && message.index("<", blank_line)
|
|
65
|
+
return first unless tag
|
|
66
|
+
return tag unless mime_header_line?(message, tag)
|
|
67
|
+
|
|
68
|
+
pos = tag
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mime_header_line?(message, index)
|
|
73
|
+
line_start = (message.rindex("\n", index) || -1) + 1
|
|
74
|
+
message[line_start...index].match?(MIME_HEADER_PREFIX)
|
|
75
|
+
end
|
|
76
|
+
|
|
28
77
|
def apply_filter(document)
|
|
29
78
|
return document unless document.errors.empty?
|
|
30
79
|
|
data/lib/savon/message.rb
CHANGED
|
@@ -22,6 +22,9 @@ module Savon
|
|
|
22
22
|
|
|
23
23
|
if @element_form_default == :qualified
|
|
24
24
|
@message = QualifiedMessage.new(@types, @used_namespaces, @key_converter).to_hash(@message, [@message_tag.to_s])
|
|
25
|
+
elsif schema_order?(@message)
|
|
26
|
+
unqualified = QualifiedMessage.new(@types, @used_namespaces, @key_converter, qualify: false)
|
|
27
|
+
@message = unqualified.to_hash(@message, [@message_tag.to_s])
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
gyoku_options = {
|
|
@@ -33,5 +36,15 @@ module Savon
|
|
|
33
36
|
|
|
34
37
|
Gyoku.xml(@message, gyoku_options)
|
|
35
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def schema_order?(value)
|
|
43
|
+
case value
|
|
44
|
+
when Hash then QualifiedMessage.order_option(value) == :use_schema || value.each_value.any? { |nested| schema_order?(nested) }
|
|
45
|
+
when Array then value.any? { |nested| schema_order?(nested) }
|
|
46
|
+
else false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
36
49
|
end
|
|
37
50
|
end
|
data/lib/savon/model.rb
CHANGED
|
@@ -45,13 +45,24 @@ module Savon
|
|
|
45
45
|
# Returns the generated Ruby method name for a SOAP operation.
|
|
46
46
|
def operation_method_name(operation)
|
|
47
47
|
method_name = StringUtils.snakecase(operation.to_s).to_sym
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
# Re-registering an operation stays allowed; any other name must not shadow a method the model already responds to.
|
|
49
|
+
return method_name if generated_operation_names.include?(method_name)
|
|
50
|
+
|
|
51
|
+
reserved_methods = instance_methods + private_instance_methods +
|
|
52
|
+
singleton_class.instance_methods + singleton_class.private_instance_methods
|
|
50
53
|
raise ArgumentError, "SOAP operation #{operation.inspect} conflicts with reserved Savon::Model method #{method_name.inspect}" if reserved_methods.include?(method_name)
|
|
51
54
|
|
|
52
55
|
method_name
|
|
53
56
|
end
|
|
54
57
|
|
|
58
|
+
# Names of operations already turned into methods on this model. The builder methods
|
|
59
|
+
# (client, global, raise_initialization_error!) live in the same modules but are not
|
|
60
|
+
# operations, so they stay reserved.
|
|
61
|
+
def generated_operation_names
|
|
62
|
+
(class_operation_module.instance_methods(false) + instance_operation_module.instance_methods(false)).uniq -
|
|
63
|
+
%i[client global raise_initialization_error!]
|
|
64
|
+
end
|
|
65
|
+
|
|
55
66
|
# Class methods.
|
|
56
67
|
def class_operation_module
|
|
57
68
|
@class_operation_module ||= Module.new do
|
data/lib/savon/operation.rb
CHANGED
|
@@ -30,6 +30,7 @@ module Savon
|
|
|
30
30
|
1 => "text/xml",
|
|
31
31
|
2 => "application/soap+xml"
|
|
32
32
|
}.freeze
|
|
33
|
+
SOAP_REQUEST_TYPE_MTOM = "application/xop+xml"
|
|
33
34
|
|
|
34
35
|
def self.create(operation_name, wsdl, globals, transport)
|
|
35
36
|
if wsdl.document?
|
|
@@ -133,9 +134,16 @@ module Savon
|
|
|
133
134
|
|
|
134
135
|
if builder.multipart
|
|
135
136
|
# RFC 2387 §3 (multipart/related) - SOAP envelope is the root body part
|
|
137
|
+
content_type = ["multipart/related"]
|
|
138
|
+
if @locals[:mtom]
|
|
139
|
+
content_type << "type=\"#{SOAP_REQUEST_TYPE_MTOM}\""
|
|
140
|
+
content_type << "start-info=\"#{SOAP_REQUEST_TYPE[@globals[:soap_version]]}\""
|
|
141
|
+
else
|
|
142
|
+
content_type << "type=\"#{SOAP_REQUEST_TYPE[@globals[:soap_version]]}\""
|
|
143
|
+
end
|
|
144
|
+
|
|
136
145
|
headers["Content-Type"] = [
|
|
137
|
-
|
|
138
|
-
"type=\"#{SOAP_REQUEST_TYPE[@globals[:soap_version]]}\"",
|
|
146
|
+
*content_type,
|
|
139
147
|
"start=\"#{builder.multipart[:start]}\"",
|
|
140
148
|
"boundary=\"#{builder.multipart[:multipart_boundary]}\""
|
|
141
149
|
].join("; ")
|
data/lib/savon/options.rb
CHANGED
|
@@ -499,7 +499,8 @@ module Savon
|
|
|
499
499
|
defaults = {
|
|
500
500
|
advanced_typecasting: true,
|
|
501
501
|
response_parser: :nokogiri,
|
|
502
|
-
multipart: false
|
|
502
|
+
multipart: false,
|
|
503
|
+
mtom: false
|
|
503
504
|
}
|
|
504
505
|
|
|
505
506
|
super defaults.merge(options)
|
|
@@ -532,7 +533,10 @@ module Savon
|
|
|
532
533
|
#
|
|
533
534
|
# should pass an Array or a Hash; items should be path strings or
|
|
534
535
|
# { filename: 'file.name', content: 'content' } objects
|
|
535
|
-
# The Content-ID in multipart message sections will be the filename or the key if Hash is given
|
|
536
|
+
# The Content-ID in multipart message sections will be the filename or the key if Hash is given.
|
|
537
|
+
# An attachment hash may set content_id: to use a different Content-ID, e.g. when the
|
|
538
|
+
# filename holds characters that are invalid in a header, or when two attachments
|
|
539
|
+
# share a filename. Without content_id:, the Content-ID defaults to the filename or key.
|
|
536
540
|
#
|
|
537
541
|
# usage examples:
|
|
538
542
|
#
|
|
@@ -562,6 +566,17 @@ module Savon
|
|
|
562
566
|
@options[:attachments] = attachments
|
|
563
567
|
end
|
|
564
568
|
|
|
569
|
+
# Emit outbound MTOM/XOP request framing for attachments.
|
|
570
|
+
#
|
|
571
|
+
# This is a low-level framing option. Savon does not rewrite normal base64 XML
|
|
572
|
+
# into XOP or process inbound MTOM responses. Callers must provide XML with
|
|
573
|
+
# xop:Include href="cid:..." references and matching attachment Content-IDs.
|
|
574
|
+
# It has no effect without attachments. An attachment hash may set
|
|
575
|
+
# content_id: to choose its Content-ID independently of its filename.
|
|
576
|
+
def mtom(mtom)
|
|
577
|
+
@options[:mtom] = mtom
|
|
578
|
+
end
|
|
579
|
+
|
|
565
580
|
# Value of the SOAPAction HTTP header.
|
|
566
581
|
def soap_action(soap_action)
|
|
567
582
|
@options[:soap_action] = soap_action
|
|
@@ -4,50 +4,130 @@ require "gyoku"
|
|
|
4
4
|
|
|
5
5
|
module Savon
|
|
6
6
|
class QualifiedMessage
|
|
7
|
-
|
|
7
|
+
# With qualify: false, keys and values are left as they are: only
|
|
8
|
+
# order!: :use_schema applies, for schemas with unqualified elements.
|
|
9
|
+
def initialize(types, used_namespaces, key_converter, qualify: true)
|
|
8
10
|
@types = types
|
|
9
11
|
@used_namespaces = used_namespaces
|
|
10
12
|
@key_converter = key_converter
|
|
13
|
+
@qualify = qualify
|
|
14
|
+
|
|
15
|
+
# Schema tables are keyed by exact element names, but message keys pass
|
|
16
|
+
# through the key converter first (:entity_ids becomes "entityIds" while
|
|
17
|
+
# the schema says "EntityIds"). Index both tables by downcased path so a
|
|
18
|
+
# converted key can still find its schema entry. Exact matches always win.
|
|
19
|
+
@types_index = index_by_normalized_path(types)
|
|
20
|
+
@used_namespaces_index = index_by_normalized_path(used_namespaces)
|
|
11
21
|
end
|
|
12
22
|
|
|
13
|
-
def to_hash(hash, path)
|
|
23
|
+
def to_hash(hash, path, schema_order: false)
|
|
14
24
|
return hash unless hash
|
|
15
|
-
return hash.map { |value| to_hash(value, path) } if hash.is_a?(Array)
|
|
16
|
-
return hash
|
|
25
|
+
return hash.map { |value| to_hash(value, path, schema_order: schema_order) } if hash.is_a?(Array)
|
|
26
|
+
return scalar(hash) unless hash.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
schema_order ||= self.class.order_option(hash) == :use_schema
|
|
29
|
+
tags_by_key = {}
|
|
17
30
|
|
|
18
|
-
hash.each_with_object({}) do |(key, value), newhash|
|
|
31
|
+
newhash = hash.each_with_object({}) do |(key, value), newhash|
|
|
19
32
|
case key
|
|
20
|
-
when :order!
|
|
21
|
-
newhash[key] = add_namespaces_to_values(value, path)
|
|
33
|
+
when :order!, "order!"
|
|
34
|
+
newhash[key] = add_namespaces_to_values(value, path) unless value == :use_schema
|
|
22
35
|
when :attributes!, :content!
|
|
23
36
|
newhash[key] = to_hash(value, path)
|
|
24
37
|
else
|
|
25
38
|
if key.to_s =~ /!$/
|
|
26
39
|
newhash[key] = value
|
|
40
|
+
tags_by_key[key] = translate_tag(key)
|
|
27
41
|
else
|
|
28
42
|
translated_key = translate_tag(key)
|
|
29
43
|
newkey = add_namespaces_to_values(key, path).first
|
|
30
44
|
newpath = path + [translated_key]
|
|
31
|
-
|
|
45
|
+
type = lookup(@types, @types_index, newpath)
|
|
46
|
+
newhash[newkey] = to_hash(value, type ? [type] : newpath, schema_order: schema_order)
|
|
47
|
+
tags_by_key[newkey] = translated_key unless key.to_s.start_with?("@")
|
|
32
48
|
end
|
|
33
49
|
end
|
|
34
50
|
newhash
|
|
35
51
|
end
|
|
52
|
+
|
|
53
|
+
newhash[:order!] = order_by_schema(tags_by_key, path) if schema_order && order_by_schema?(hash)
|
|
54
|
+
newhash
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.order_option(hash)
|
|
58
|
+
hash.key?(:order!) ? hash[:order!] : hash["order!"]
|
|
36
59
|
end
|
|
37
60
|
|
|
38
61
|
private
|
|
39
62
|
|
|
63
|
+
# An explicit order! wins, and a hash with content! is a single element.
|
|
64
|
+
def order_by_schema?(hash)
|
|
65
|
+
[nil, :use_schema].include?(self.class.order_option(hash)) && !hash.key?(:content!)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def scalar(value)
|
|
69
|
+
@qualify ? value.to_s : value
|
|
70
|
+
end
|
|
71
|
+
|
|
40
72
|
def translate_tag(key)
|
|
41
73
|
Gyoku.xml_tag(key, key_converter: @key_converter).to_s
|
|
42
74
|
end
|
|
43
75
|
|
|
44
76
|
def add_namespaces_to_values(values, path)
|
|
77
|
+
return Array(values) unless @qualify
|
|
78
|
+
|
|
45
79
|
Array(values).collect do |value|
|
|
46
80
|
translated_value = translate_tag(value)
|
|
47
81
|
namespace_path = path + [translated_value]
|
|
48
|
-
namespace = @used_namespaces
|
|
82
|
+
namespace = lookup(@used_namespaces, @used_namespaces_index, namespace_path) || ''
|
|
49
83
|
namespace.empty? ? value : "#{namespace}:#{translated_value}"
|
|
50
84
|
end
|
|
51
85
|
end
|
|
86
|
+
|
|
87
|
+
# Wasabi doesn't record elements inside an xs:choice or xs:group, so they
|
|
88
|
+
# are unknown keys too: raise rather than guess where they go.
|
|
89
|
+
def order_by_schema(tags_by_key, path)
|
|
90
|
+
children = schema_children(path)
|
|
91
|
+
if children.empty? && tags_by_key.any?
|
|
92
|
+
raise Error, "order!: :use_schema found no schema sequence for #{path.join('/')}: " \
|
|
93
|
+
"give this level its own order! array"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
position_by_tag = children.each_with_index.to_h
|
|
97
|
+
unknown_tags = tags_by_key.values.reject { |tag| position_by_tag.key?(tag.downcase) }
|
|
98
|
+
if unknown_tags.any?
|
|
99
|
+
raise Error, "order!: :use_schema found no schema element for #{unknown_tags.join(', ')} in #{path.join('/')}: " \
|
|
100
|
+
"give this level its own order! array"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
tags_by_key.keys.sort_by { |key| position_by_tag.fetch(tags_by_key[key].downcase) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def schema_children(path)
|
|
107
|
+
parent = normalize_path(path)
|
|
108
|
+
(@schema_children ||= {})[parent] ||= @used_namespaces_index.keys.filter_map { |child|
|
|
109
|
+
child.last if child.length == parent.length + 1 && child.first(parent.length) == parent
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Finds the schema entry for a message path. Converted message keys rarely
|
|
114
|
+
# match schema element names exactly ("entityIds" vs "EntityIds"), so fall
|
|
115
|
+
# back to a case-insensitive match when the exact lookup misses.
|
|
116
|
+
def lookup(table, index, path)
|
|
117
|
+
return table[path] if table.key?(path)
|
|
118
|
+
|
|
119
|
+
original = index[normalize_path(path)]
|
|
120
|
+
original && table[original]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def normalize_path(path)
|
|
124
|
+
path.map { |segment| segment.to_s.downcase }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def index_by_normalized_path(table)
|
|
128
|
+
table.each_with_object({}) do |(path, _), memo|
|
|
129
|
+
memo[normalize_path(path)] ||= path
|
|
130
|
+
end
|
|
131
|
+
end
|
|
52
132
|
end
|
|
53
133
|
end
|
|
@@ -53,7 +53,14 @@ module Savon
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def body_to_log(body)
|
|
56
|
-
LogMessage.new(body, @globals[:filters], @globals[:pretty_print_xml]).to_s
|
|
56
|
+
message = LogMessage.new(body, @globals[:filters], @globals[:pretty_print_xml]).to_s
|
|
57
|
+
|
|
58
|
+
# A multipart body is tagged binary only so its parts can be concatenated: the text parts are already in the
|
|
59
|
+
# log encoding. Transcoding from ASCII-8BIT would replace every non-ASCII byte, the text included, so relabel
|
|
60
|
+
# the bytes and replace only those that are invalid in the log encoding (the binary attachments).
|
|
61
|
+
return message.dup.force_encoding(@globals[:encoding]).scrub if message.encoding == Encoding::BINARY
|
|
62
|
+
|
|
63
|
+
message.encode(@globals[:encoding], invalid: :replace, undef: :replace)
|
|
57
64
|
end
|
|
58
65
|
end
|
|
59
66
|
end
|
data/lib/savon/version.rb
CHANGED
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.
|
|
4
|
+
version: 2.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Harrington
|
|
@@ -245,6 +245,20 @@ dependencies:
|
|
|
245
245
|
- - "~>"
|
|
246
246
|
- !ruby/object:Gem::Version
|
|
247
247
|
version: '3.9'
|
|
248
|
+
- !ruby/object:Gem::Dependency
|
|
249
|
+
name: webmock
|
|
250
|
+
requirement: !ruby/object:Gem::Requirement
|
|
251
|
+
requirements:
|
|
252
|
+
- - "~>"
|
|
253
|
+
- !ruby/object:Gem::Version
|
|
254
|
+
version: '3.0'
|
|
255
|
+
type: :development
|
|
256
|
+
prerelease: false
|
|
257
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
258
|
+
requirements:
|
|
259
|
+
- - "~>"
|
|
260
|
+
- !ruby/object:Gem::Version
|
|
261
|
+
version: '3.0'
|
|
248
262
|
description: Heavy metal SOAP client
|
|
249
263
|
email: me@rubiii.com
|
|
250
264
|
executables: []
|