Skip to content

Commit

Permalink
Add multipart request sending option (savonrb#761)
Browse files Browse the repository at this point in the history
* Add multipart request sending option (https://www.w3.org/TR/SOAP-attachments)
  • Loading branch information
dukaarpad authored and pcai committed Apr 29, 2018
1 parent 15284ea commit 4e7ae5e
Show file tree
Hide file tree
Showing 9 changed files with 275 additions and 90 deletions.
113 changes: 85 additions & 28 deletions lib/savon/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

module Savon
class Builder
attr_reader :multipart

SCHEMA_TYPES = {
"xmlns:xsd" => "http://www.w3.org/2001/XMLSchema",
Expand Down Expand Up @@ -37,35 +38,27 @@ def pretty
end

def build_document
xml_result = tag(builder, :Envelope, namespaces_with_globals) do |xml|
tag(xml, :Header, header_attributes) { xml << header.to_s } unless header.empty?
if @globals[:no_message_tag]
tag(xml, :Body, body_attributes) { xml << message.to_s }
else
tag(xml, :Body, body_attributes) { xml.tag!(*namespaced_message_tag) { xml << body_message } }
end
end
xml_result = build_xml

# if we have a signature sign the document
if @signature
@signature.document = xml_result

2.times do
@header = nil
@signature.document = tag(builder, :Envelope, namespaces_with_globals) do |xml|
tag(xml, :Header, header_attributes) { xml << header.to_s } unless header.empty?
if @globals[:no_message_tag]
tag(xml, :Body, body_attributes) { xml << message.to_s }
else
tag(xml, :Body, body_attributes) { xml.tag!(*namespaced_message_tag) { xml << message.to_s } }
end
end
@signature.document = build_xml
end

xml_result = @signature.document
end

xml_result
# if there are attachments for the request, we should build a multipart message according to
# https://www.w3.org/TR/SOAP-attachments
if @locals[:attachments]
build_multipart_message(xml_result)
else
xml_result
end
end

def header_attributes
Expand Down Expand Up @@ -118,15 +111,13 @@ def namespaces
@namespaces ||= begin
namespaces = SCHEMA_TYPES.dup

if namespace_identifier == nil
namespaces["xmlns"] = @globals[:namespace] || @wsdl.namespace
else
namespaces["xmlns:#{namespace_identifier}"] = @globals[:namespace] || @wsdl.namespace
end
# check namespace_identifier
namespaces["xmlns#{namespace_identifier.nil? ? '' : ":#{namespace_identifier}"}"] =
@globals[:namespace] || @wsdl.namespace

key = ["xmlns"]
key << env_namespace if env_namespace && env_namespace != ""
namespaces[key.join(":")] = SOAP_NAMESPACE[@globals[:soap_version]]
# check env_namespace
namespaces["xmlns#{env_namespace && env_namespace != "" ? ":#{env_namespace}" : ''}"] =
SOAP_NAMESPACE[@globals[:soap_version]]

namespaces
end
Expand Down Expand Up @@ -169,12 +160,14 @@ def serialized_messages
end

def message_tag
message_tag = @wsdl.soap_input(@operation_name.to_sym).keys.first if @wsdl.document? and @wsdl.soap_input(@operation_name.to_sym).is_a?(Hash)
wsdl_tag_name = @wsdl.document? && @wsdl.soap_input(@operation_name.to_sym)

message_tag = wsdl_tag_name.keys.first if wsdl_tag_name.is_a?(Hash)
message_tag ||= @locals[:message_tag]
message_tag ||= @wsdl.soap_input(@operation_name.to_sym) if @wsdl.document?
message_tag ||= wsdl_tag_name
message_tag ||= Gyoku.xml_tag(@operation_name, :key_converter => @globals[:convert_request_keys_to])

@message_tag = message_tag.to_sym
message_tag.to_sym
end

def message_attributes
Expand Down Expand Up @@ -228,5 +221,69 @@ def tag(xml, name, namespaces = {}, &block)
end
end

def build_xml
tag(builder, :Envelope, namespaces_with_globals) do |xml|
tag(xml, :Header, header_attributes) { xml << header.to_s } unless header.empty?
tag(xml, :Body, body_attributes) do
if @globals[:no_message_tag]
xml << message.to_s
else
xml.tag!(*namespaced_message_tag) { xml << body_message }
end
end
end
end

def build_multipart_message(message_xml)
multipart_message = init_multipart_message(message_xml)
add_attachments_to_multipart_message(multipart_message)

multipart_message.ready_to_send!

# the mail.body.encoded algorithm reorders the parts, default order is [ "text/plain", "text/enriched", "text/html" ]
# should redefine the sort order, because the soap request xml should be the first
multipart_message.body.set_sort_order [ "text/xml" ]

multipart_message.body.encoded(multipart_message.content_transfer_encoding)
end

def init_multipart_message(message_xml)
multipart_message = Mail.new
xml_part = Mail::Part.new do
content_type 'text/xml'
body message_xml
# in Content-Type the start parameter is recommended (RFC 2387)
content_id '<soap-request-body@soap>'
end
multipart_message.add_part xml_part

#request.headers["Content-Type"] = "multipart/related; boundary=\"#{multipart_message.body.boundary}\"; type=\"text/xml\"; start=\"#{xml_part.content_id}\""
@multipart = {
multipart_boundary: multipart_message.body.boundary,
start: xml_part.content_id,
}

multipart_message
end

def add_attachments_to_multipart_message(multipart_message)
if @locals[:attachments].is_a? Hash
# hash example: { 'att1' => '/path/to/att1', 'att2' => '/path/to/att2' }
@locals[:attachments].each do |identifier, attachment|
add_attachment_to_multipart_message(multipart_message, attachment, identifier)
end
elsif @locals[:attachments].is_a? Array
# array example: [ '/path/to/att1', '/path/to/att2' ]
# array example: [ { filename: 'att1.xml', content: '<x/>' }, { filename: 'att2.xml', content: '<y/>' } ]
@locals[:attachments].each do |attachment|
add_attachment_to_multipart_message(multipart_message, attachment, attachment.is_a?(String) ? File.basename(attachment) : attachment[:filename])
end
end
end

def add_attachment_to_multipart_message(multipart_message, attachment, identifier)
multipart_message.add_file attachment.clone
multipart_message.parts.last.content_id = multipart_message.parts.last.content_location = identifier.to_s
end
end
end
29 changes: 11 additions & 18 deletions lib/savon/operation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "savon/response"
require "savon/request_logger"
require "savon/http_error"
require "mail"

module Savon
class Operation
Expand Down Expand Up @@ -67,21 +68,7 @@ def request(locals = {}, &block)
private

def create_response(response)
if multipart_supported?
Multipart::Response.new(response, @globals, @locals)
else
Response.new(response, @globals, @locals)
end
end

def multipart_supported?
return false unless @globals[:multipart] || @locals[:multipart]

if Savon.const_defined? :Multipart
true
else
raise 'Unable to find Savon::Multipart. Make sure the savon-multipart gem is installed and loaded.'
end
Response.new(response, @globals, @locals)
end

def set_locals(locals, block)
Expand Down Expand Up @@ -112,6 +99,12 @@ def build_request(builder)
# was not specified manually? [dh, 2013-01-04]
request.headers["Content-Length"] = request.body.bytesize.to_s

if builder.multipart
request.headers["Content-Type"] = "multipart/related; " \
"boundary=\"#{builder.multipart[:multipart_boundary]}\"; " \
"type=\"text/xml\"; start=\"#{builder.multipart[:start]}\""
end

request
end

Expand All @@ -120,11 +113,11 @@ def soap_action
return if @locals.include?(:soap_action) && !@locals[:soap_action]

# get the soap_action from local options
soap_action = @locals[:soap_action]
@locals[:soap_action] ||
# with no local option, but a wsdl, ask it for the soap_action
soap_action ||= @wsdl.soap_action(@name.to_sym) if @wsdl.document?
@wsdl.document? && @wsdl.soap_action(@name.to_sym) ||
# if there is no soap_action up to this point, fallback to a simple default
soap_action ||= Gyoku.xml_tag(@name, :key_converter => @globals[:convert_request_keys_to])
Gyoku.xml_tag(@name, :key_converter => @globals[:convert_request_keys_to])
end

def endpoint
Expand Down
34 changes: 34 additions & 0 deletions lib/savon/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,40 @@ def attributes(attributes)
@options[:attributes] = attributes
end

# Attachments for the SOAP message (https://www.w3.org/TR/SOAP-attachments)
#
# should pass an Array or a Hash; items should be path strings or
# { filename: 'file.name', content: 'content' } objects
# The Content-ID in multipart message sections will be the filename or the key if Hash is given
#
# usage examples:
#
# response = client.call :operation1 do
# message param1: 'value'
# attachments [
# { filename: 'x1.xml', content: '<xml>abc</xml>'},
# { filename: 'x2.xml', content: '<xml>abc</xml>'}
# ]
# end
# # Content-ID will be x1.xml and x2.xml
#
# response = client.call :operation1 do
# message param1: 'value'
# attachments 'x1.xml' => '/tmp/1281ab7d7d.xml', 'x2.xml' => '/tmp/4c5v8e833a.xml'
# end
# # Content-ID will be x1.xml and x2.xml
#
# response = client.call :operation1 do
# message param1: 'value'
# attachments [ '/tmp/1281ab7d7d.xml', '/tmp/4c5v8e833a.xml']
# end
# # Content-ID will be 1281ab7d7d.xml and 4c5v8e833a.xml
#
# The Content-ID is important if you want to refer to the attachments from the SOAP request
def attachments(attachments)
@options[:attachments] = attachments
end

# Value of the SOAPAction HTTP header.
def soap_action(soap_action)
@options[:soap_action] = soap_action
Expand Down
3 changes: 2 additions & 1 deletion lib/savon/request_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ def log_request(request)

def log_response(response)
logger.info { "SOAP response (status #{response.code})" }
logger.debug { headers_to_log(response.headers) }
logger.debug { body_to_log(response.body) }
end

def headers_to_log(headers)
headers.map { |key, value| "#{key}: #{value}" }.join(", ")
headers.map { |key, value| "#{key}: #{value}" }.join("\n")
end

def body_to_log(body)
Expand Down
48 changes: 47 additions & 1 deletion lib/savon/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@

module Savon
class Response
CRLF = /\r\n/
WSP = /[#{%Q|\x9\x20|}]/

def initialize(http, globals, locals)
@http = http
@globals = globals
@locals = locals
@attachments = []
@xml = ''
@has_parsed_body = false

build_soap_and_http_errors!
raise_soap_and_http_errors! if @globals[:raise_errors]
Expand Down Expand Up @@ -54,7 +59,12 @@ def hash
end

def xml
@http.body
if multipart?
parse_body unless @has_parsed_body
@xml
else
@http.body
end
end

alias_method :to_xml, :xml
Expand All @@ -75,8 +85,44 @@ def find(*path)
nori.find(envelope, *path)
end

def attachments
if multipart?
parse_body unless @has_parsed_body
@attachments
else
[]
end
end

def multipart?
!(http.headers['content-type'] =~ /^multipart/im).nil?
end

private

def boundary
return unless multipart?
Mail::Field.new('content-type', http.headers['content-type']).parameters['boundary']
end

def parse_body
http.body.force_encoding Encoding::ASCII_8BIT
parts = http.body.split(/(?:\A|\r\n)--#{Regexp.escape(boundary)}(?=(?:--)?\s*$)/)
parts[1..-1].to_a.each_with_index do |part, index|
header_part, body_part = part.lstrip.split(/#{CRLF}#{CRLF}|#{CRLF}#{WSP}*#{CRLF}(?!#{WSP})/m, 2)
section = Mail::Part.new(
body: body_part
)
section.header = header_part
if index == 0
@xml = section.body.to_s
else
@attachments << section
end
end
@has_parsed_body = true
end

def build_soap_and_http_errors!
@soap_fault = SOAPFault.new(@http, nori, xml) if soap_fault?
@http_error = HTTPError.new(@http) if http_error?
Expand Down
1 change: 1 addition & 0 deletions savon.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Gem::Specification.new do |s|
s.add_dependency "gyoku", "~> 1.2"
s.add_dependency "builder", ">= 2.1.2"
s.add_dependency "nokogiri", ">= 1.8.1"
s.add_dependency "mail", "~> 2.5"

s.add_development_dependency "rack"
s.add_development_dependency "puma", "~> 3.0"
Expand Down
Loading

0 comments on commit 4e7ae5e

Please sign in to comment.