diff --git a/src/SimpleSOAPClient/Exceptions/SoapAttachmentDeserializationException.cs b/src/SimpleSOAPClient/Exceptions/SoapAttachmentDeserializationException.cs new file mode 100644 index 0000000..47284ff --- /dev/null +++ b/src/SimpleSOAPClient/Exceptions/SoapAttachmentDeserializationException.cs @@ -0,0 +1,20 @@ +using System; + +namespace SimpleSOAPClient.Exceptions +{ + /// + /// Thrown when a problem is encountered deserializing MTOM attachments. + /// + public class SoapAttachmentDeserializationException : SoapClientException + { + /// + public SoapAttachmentDeserializationException(string message) : base(message) + { + } + + /// + public SoapAttachmentDeserializationException(string message, Exception innerException) : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/SimpleSOAPClient/Models/SoapEnvelope.cs b/src/SimpleSOAPClient/Models/SoapEnvelope.cs index 1bcfd32..86f6a93 100644 --- a/src/SimpleSOAPClient/Models/SoapEnvelope.cs +++ b/src/SimpleSOAPClient/Models/SoapEnvelope.cs @@ -21,6 +21,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. #endregion + +using System.Collections.Generic; +using System.Net.Http; + namespace SimpleSOAPClient.Models { using System.Xml.Serialization; @@ -42,6 +46,13 @@ public class SoapEnvelope /// [XmlElement("Body")] public SoapEnvelopeBody Body { get; set; } + + /// + /// MTOM attachments of the SOAP message. + /// See https://www.w3.org/TR/soap12-mtom. + /// + [XmlIgnore] + public IDictionary Attachments { get; set; } /// /// Initializes a new instance of diff --git a/src/SimpleSOAPClient/Models/Xop/Include.cs b/src/SimpleSOAPClient/Models/Xop/Include.cs new file mode 100644 index 0000000..adb6de1 --- /dev/null +++ b/src/SimpleSOAPClient/Models/Xop/Include.cs @@ -0,0 +1,42 @@ +using System; +using System.Xml.Serialization; + +namespace SimpleSOAPClient.Models.Xop +{ + + /// + /// A reference to an attachment to the SoapEnvelope; see https://www.w3.org/TR/soap12-mtom/ + /// + [XmlType(Namespace = Namespace)] + public class Include + { + /// + /// The W3 namespace for XOP Include + /// + public const string Namespace = "http://www.w3.org/2004/08/xop/include"; + + /// + /// The Content Id of the associated attachment. + /// + [XmlIgnore] + public string ContentId { get; set; } + + /// + /// Helper method to generate the href attribute when serialized to XML. + /// + [XmlAttribute(AttributeName = "href")] + public string ContentIdAsHref + { + get => "cid:" + ContentId; + set + { + if (!value.StartsWith("cid:", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Content Id must start 'cid:'."); + } + ContentId = value.Substring(4, value.Length - 4); + } + } + + } +} \ No newline at end of file diff --git a/src/SimpleSOAPClient/SimpleSOAPClient.csproj b/src/SimpleSOAPClient/SimpleSOAPClient.csproj index 03e0f6b..459e352 100644 --- a/src/SimpleSOAPClient/SimpleSOAPClient.csproj +++ b/src/SimpleSOAPClient/SimpleSOAPClient.csproj @@ -43,6 +43,7 @@ + @@ -51,8 +52,13 @@ + + + + + diff --git a/src/SimpleSOAPClient/SoapClient.cs b/src/SimpleSOAPClient/SoapClient.cs index 6573b18..008f658 100644 --- a/src/SimpleSOAPClient/SoapClient.cs +++ b/src/SimpleSOAPClient/SoapClient.cs @@ -21,6 +21,9 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. #endregion + +using System.Text.RegularExpressions; + namespace SimpleSOAPClient { using System; @@ -33,12 +36,14 @@ namespace SimpleSOAPClient using Exceptions; using Handlers; using Models; + /// /// The SOAP client that can be used to invoke SOAP Endpoints /// public class SoapClient : ISoapClient, IDisposable { + private readonly Regex _cidRegex = new Regex("^<(.*)>$"); private readonly bool _disposeHttpClient = true; private readonly List _handlers = new List(); private SoapClientSettings _settings; @@ -198,12 +203,43 @@ await RunBeforeSoapEnvelopeSerializationHandlers( await RunBeforeHttpRequestHandlers( requestXml, url, action, trackingId, beforeSoapEnvelopeSerializationHandlersResult.State, handlersOrderedAsc, ct); - + var response = await HttpClient.SendAsync(beforeHttpRequestHandlersResult.Request, ct).ConfigureAwait(false); - + + // Handle multipart responses + // DotNet Standard 1.1 isn't supported by the multipart library +#if !NETSTANDARD1_1 + IDictionary attachments = new Dictionary(); + if (response.Content.IsMimeMultipartContent()) + { + var multipart = await response.Content.ReadAsMultipartAsync(ct); + foreach (var content in multipart.Contents) + { + if (content.Headers.ContentType.MediaType == "application/xop+xml") + { + // This part contains the Soap Envelop XML + response.Content = content; + } + else + { + // Any other part is an attachment and should have a content id + var cidMatch = _cidRegex.Match(content.Headers.GetValues("Content-Id").First()); + if (cidMatch.Success) + { + attachments.Add(cidMatch.Groups[1].Value, content); + } + else + { + throw new SoapAttachmentDeserializationException("Multipart message part without content id found; all attachments much have a content id."); + } + } + } + } +#endif + var handlersOrderedDesc = _handlers.OrderByDescending(e => e.Order).ToList(); - + var afterHttpResponseHandlersResult = await RunAfterHttpResponseHandlers( response, url, action, trackingId, beforeHttpRequestHandlersResult.State, handlersOrderedDesc, ct); @@ -218,6 +254,9 @@ await RunAfterHttpResponseHandlers( { responseEnvelope = Settings.SerializationProvider.ToSoapEnvelope(responseXml); +#if !NETSTANDARD1_1 + responseEnvelope.Attachments = attachments; +#endif } catch (SoapEnvelopeDeserializationException) { diff --git a/tests/SimpleSOAPClient.Tests/MultipartResponseTests.cs b/tests/SimpleSOAPClient.Tests/MultipartResponseTests.cs new file mode 100644 index 0000000..3eef619 --- /dev/null +++ b/tests/SimpleSOAPClient.Tests/MultipartResponseTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SimpleSOAPClient.Models; +using Xunit; + +namespace SimpleSOAPClient.Tests +{ + public class MultipartResponseTests + { + private const string AttachmentContents = "The Attachment Contents"; + private const string AttachmentContentId = "test-content@test.com"; + + [Fact] + public async Task TestReceiveAndDeserializeSingleAttachment() + { + var httpMessageHandler = new TestHttpMessageHandler(); + var httpClient = new HttpClient(httpMessageHandler); + var soapClient = new SoapClient(httpClient); + + var result = await soapClient.SendAsync("http://test.com", "", SoapEnvelope.Prepare()); + Assert.Equal(AttachmentContents, await result.Attachments[AttachmentContentId].ReadAsStringAsync()); + } + + + private class TestHttpMessageHandler : HttpMessageHandler + { + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var emptyEnvelope = new SoapEnvelopeSerializationProvider().ToXmlString(SoapEnvelope.Prepare()); + var multipartResponse = new MultipartContent("related") + { + new StringContent(emptyEnvelope, Encoding.UTF8, "application/xop+xml"), + new StringContent(AttachmentContents, Encoding.UTF8, "text/plain") + { + Headers = {{"Content-Id", $"<{AttachmentContentId}>"}} + } + }; + + return Task.FromResult(new HttpResponseMessage + { + Version = HttpVersion.Version11, + Content = multipartResponse, + StatusCode = HttpStatusCode.OK, + ReasonPhrase = "OK", + }); + } + } + } +} \ No newline at end of file