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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebee0f0be688d000144d03f78f17399219a59b9acdfa83a1416c603eaec79112
4
- data.tar.gz: 3a5b2385872f10d4664f2cc298569e40249fe29e689194f0862148c0a474d5b1
3
+ metadata.gz: b824b46b6baa9ec754839a85d8f4225b62ba562b4d060143c6b02cd41e4880f8
4
+ data.tar.gz: fbaf6f7ed2607e3c24d356c6ffb282f089b1426fc89b8fdcde5c01918c657e9a
5
5
  SHA512:
6
- metadata.gz: 15ee4696bf210268256cd8110312f1e329524ae7c9efe6cebac86dfa35f918dfb7a7583667f7ae12e46504da7ee3de36e015d643aafc5472cfcef5eefd15bb88
7
- data.tar.gz: ea85b81ec385af37c4e6e992b646c16bbfd8ff03f64cd4ce697f5dcabe6ba316c3d7829262caf73f14a30858c3c1af5fb3e73663a6b278748e8a374f8f384005
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
- 2.times do
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 = @wsdl.document? ? @wsdl.parser.namespaces.key(uri) : nil
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(message_tag, namespace_identifier, @types, @used_namespaces, message_locals, :unqualified, @globals[:convert_request_keys_to], @globals[:unwrap]).to_s
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.each do |candidate_identifier, candidate_uri|
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 ["text/xml"]
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 'text/xml'
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
- add_attachment_to_multipart_message(multipart_message, attachment, attachment.is_a?(String) ? File.basename(attachment) : attachment[:filename])
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
- multipart_message.add_file attachment.clone
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. The
49
- # global +:host+ option replaces host and port of the WSDL address and
50
- # keeps scheme, path and query. The override is applied to a copy. The
51
- # WSDL document keeps its parsed address.
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
- return @wsdl.endpoint unless @globals[:host]
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 = @wsdl.endpoint.dup
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.
@@ -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
- message_is_xml = @message =~ /^</
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
- document = Nokogiri.XML(@message)
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
- reserved_methods = Model.instance_methods(false) + Model.private_instance_methods(false) +
49
- %i[client global raise_initialization_error!]
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
@@ -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
- "multipart/related",
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
- def initialize(types, used_namespaces, key_converter)
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.to_s unless hash.is_a?(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
- newhash[newkey] = to_hash(value, @types[newpath] ? [@types[newpath]] : newpath)
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[namespace_path] || ''
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Savon
4
- VERSION = '2.17.5'
4
+ VERSION = '2.18.0'
5
5
  end
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.17.5
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: []