diff --git a/Refit.Tests/MultipartTests.cs b/Refit.Tests/MultipartTests.cs index 1301278..73230ff 100644 --- a/Refit.Tests/MultipartTests.cs +++ b/Refit.Tests/MultipartTests.cs @@ -402,9 +402,16 @@ namespace Refit.Tests } } - [Fact] - public async Task MultipartUploadShouldWorkWithAnObject() + [Theory] + [InlineData(typeof(JsonContentSerializer), "application/json")] + [InlineData(typeof(XmlContentSerializer), "application/xml")] + public async Task MultipartUploadShouldWorkWithAnObject(Type contentSerializerType, string mediaType) { + if (!(Activator.CreateInstance(contentSerializerType) is IContentSerializer serializer)) + { + throw new ArithmeticException($"{contentSerializerType.FullName} does not implement {nameof(IContentSerializer)}"); + } + var model1 = new ModelObject { Property1 = "M1.prop1", @@ -421,8 +428,8 @@ namespace Refit.Tests Assert.Equal("theObject", parts[0].Headers.ContentDisposition.Name); Assert.Null(parts[0].Headers.ContentDisposition.FileName); - Assert.Equal("application/json", parts[0].Headers.ContentType.MediaType); - var result0 = JsonConvert.DeserializeObject(await parts[0].ReadAsStringAsync()); + Assert.Equal(mediaType, parts[0].Headers.ContentType.MediaType); + var result0 = serializer.Deserialize(await parts[0].ReadAsStringAsync()); Assert.Equal(model1.Property1, result0.Property1); Assert.Equal(model1.Property2, result0.Property2); } @@ -430,16 +437,24 @@ namespace Refit.Tests var settings = new RefitSettings() { - HttpMessageHandlerFactory = () => handler + HttpMessageHandlerFactory = () => handler, + ContentSerializer = serializer }; var fixture = RestService.For(BaseAddress, settings); var result = await fixture.UploadJsonObject(model1); } - [Fact] - public async Task MultipartUploadShouldWorkWithObjects() + [Theory] + [InlineData(typeof(JsonContentSerializer), "application/json")] + [InlineData(typeof(XmlContentSerializer), "application/xml")] + public async Task MultipartUploadShouldWorkWithObjects(Type contentSerializerType, string mediaType) { + if (!(Activator.CreateInstance(contentSerializerType) is IContentSerializer serializer)) + { + throw new ArithmeticException($"{contentSerializerType.FullName} does not implement {nameof(IContentSerializer)}"); + } + var model1 = new ModelObject { Property1 = "M1.prop1", @@ -451,7 +466,6 @@ namespace Refit.Tests Property1 = "M2.prop1" }; - var handler = new MockHttpMessageHandler { Asserts = async content => @@ -462,16 +476,16 @@ namespace Refit.Tests Assert.Equal("theObjects", parts[0].Headers.ContentDisposition.Name); Assert.Null(parts[0].Headers.ContentDisposition.FileName); - Assert.Equal("application/json", parts[0].Headers.ContentType.MediaType); - var result0 = JsonConvert.DeserializeObject(await parts[0].ReadAsStringAsync()); + Assert.Equal(mediaType, parts[0].Headers.ContentType.MediaType); + var result0 = serializer.Deserialize(await parts[0].ReadAsStringAsync()); Assert.Equal(model1.Property1, result0.Property1); Assert.Equal(model1.Property2, result0.Property2); Assert.Equal("theObjects", parts[1].Headers.ContentDisposition.Name); Assert.Null(parts[1].Headers.ContentDisposition.FileName); - Assert.Equal("application/json", parts[1].Headers.ContentType.MediaType); - var result1 = JsonConvert.DeserializeObject(await parts[1].ReadAsStringAsync()); + Assert.Equal(mediaType, parts[1].Headers.ContentType.MediaType); + var result1 = serializer.Deserialize(await parts[1].ReadAsStringAsync()); Assert.Equal(model2.Property1, result1.Property1); Assert.Equal(model2.Property2, result1.Property2); } @@ -479,7 +493,8 @@ namespace Refit.Tests var settings = new RefitSettings() { - HttpMessageHandlerFactory = () => handler + HttpMessageHandlerFactory = () => handler, + ContentSerializer = serializer }; var fixture = RestService.For(BaseAddress, settings); diff --git a/Refit.Tests/RestService.cs b/Refit.Tests/RestService.cs index 7fe72ca..3507614 100644 --- a/Refit.Tests/RestService.cs +++ b/Refit.Tests/RestService.cs @@ -11,6 +11,7 @@ using Xunit; using Refit; // InterfaceStubGenerator looks for this using RichardSzalay.MockHttp; using System.IO; +using System.Text; namespace Refit.Tests { @@ -1107,5 +1108,33 @@ namespace Refit.Tests mockHttp.VerifyNoOutstandingExpectation(); } + + [Fact] + public async Task CanSerializeContentAsXml() + { + var mockHttp = new MockHttpMessageHandler(); + var contentSerializer = new XmlContentSerializer(); + var settings = new RefitSettings + { + HttpMessageHandlerFactory = () => mockHttp, + ContentSerializer = contentSerializer + }; + + mockHttp + .Expect(HttpMethod.Post, "/users") + .WithHeaders("Content-Type:application/xml; charset=utf-8") + .Respond(req => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Created", Encoding.UTF8, "application/xml") + }); + + var fixture = RestService.For("https://api.github.com", settings); + + var result = await fixture.CreateUser(new User()).ConfigureAwait(false); + + Assert.Equal("Created", result.Name); + + mockHttp.VerifyNoOutstandingExpectation(); + } } } diff --git a/Refit.Tests/XmlContentSerializerTests.cs b/Refit.Tests/XmlContentSerializerTests.cs new file mode 100644 index 0000000..d48b93f --- /dev/null +++ b/Refit.Tests/XmlContentSerializerTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; +using Xunit; + +namespace Refit.Tests +{ + public class XmlContentSerializerTests + { + public class Dto + { + public DateTime CreatedOn { get; set; } + + public string Identifier { get; set; } + + [XmlElement(Namespace = "https://google.com")] + public string Name { get; set; } + } + + [Fact] + public void MediaTypeShouldBeApplicationXml() + { + var dto = BuildDto(); + var sut = new XmlContentSerializer(); + + var content = sut.Serialize(dto); + + Assert.Equal("application/xml", content.Headers.ContentType.MediaType); + } + + [Fact] + public async Task ShouldSerializeToXml() + { + var dto = BuildDto(); + var sut = new XmlContentSerializer(); + + var content = sut.Serialize(dto); + var document = new XmlDocument(); + document.LoadXml(await content.ReadAsStringAsync()); + + var root = document[nameof(Dto)] ?? throw new NullReferenceException("Root element was not found"); + Assert.Equal(dto.CreatedOn, XmlConvert.ToDateTime(root[nameof(Dto.CreatedOn)].InnerText, XmlDateTimeSerializationMode.Utc)); + Assert.Equal(dto.Identifier, root[nameof(Dto.Identifier)].InnerText); + Assert.Equal(dto.Name, root[nameof(Dto.Name)].InnerText); + } + + [Fact] + public async Task ShouldSerializeToXmlUsingAttributeOverrides() + { + const string overridenRootElementName = "dto-ex"; + + var dto = BuildDto(); + var serializerSettings = new XmlContentSerializerSettings(); + var attributes = new XmlAttributes { XmlRoot = new XmlRootAttribute(overridenRootElementName) }; + serializerSettings.XmlAttributeOverrides.Add(dto.GetType(), attributes); + var sut = new XmlContentSerializer(serializerSettings); + + var content = sut.Serialize(dto); + var document = new XmlDocument(); + document.LoadXml(await content.ReadAsStringAsync()); + + Assert.Equal(overridenRootElementName, document.DocumentElement?.Name); + } + + [Fact] + public async Task ShouldSerializeToXmlUsingNamespaceOverrides() + { + const string prefix = "google"; + + var dto = BuildDto(); + var serializerSettings = new XmlContentSerializerSettings { XmlNamespaces = new XmlSerializerNamespaces() }; + serializerSettings.XmlNamespaces.Add(prefix, "https://google.com"); + var sut = new XmlContentSerializer(serializerSettings); + + var content = sut.Serialize(dto); + var document = new XmlDocument(); + document.LoadXml(await content.ReadAsStringAsync()); + + Assert.Equal(prefix, document["Dto"]?["Name", "https://google.com"]?.Prefix); + } + + [Fact] + public void ShouldDeserializeFromXml() + { + var serializerSettings = new XmlContentSerializerSettings { XmlNamespaces = new XmlSerializerNamespaces() }; + var sut = new XmlContentSerializer(serializerSettings); + + var dto = sut.Deserialize("123"); + + Assert.Equal("123", dto.Identifier); + } + + private static Dto BuildDto() + { + var dto = new Dto { + CreatedOn = DateTime.UtcNow, + Identifier = Guid.NewGuid().ToString(), + Name = "Test Dto Object" + }; + return dto; + } + } +} diff --git a/Refit/ApiException.cs b/Refit/ApiException.cs index 541707e..4dca6e9 100644 --- a/Refit/ApiException.cs +++ b/Refit/ApiException.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; @@ -17,11 +18,8 @@ namespace Refit public HttpMethod HttpMethod { get; } public Uri Uri => RequestMessage.RequestUri; public HttpRequestMessage RequestMessage { get; } - public HttpContentHeaders ContentHeaders { get; private set; } - public string Content { get; private set; } - public bool HasContent => !string.IsNullOrWhiteSpace(Content); public RefitSettings RefitSettings { get; set; } @@ -37,7 +35,7 @@ namespace Refit } public T GetContentAs() => HasContent ? - JsonConvert.DeserializeObject(Content, RefitSettings.JsonSerializerSettings) : + RefitSettings.ContentSerializer.Deserialize(Content) : default; #pragma warning disable VSTHRD200 // Use "Async" suffix for async methods diff --git a/Refit/JsonContentSerializer.cs b/Refit/JsonContentSerializer.cs new file mode 100644 index 0000000..af840f7 --- /dev/null +++ b/Refit/JsonContentSerializer.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Refit { + + public class JsonContentSerializer : IContentSerializer + { + private readonly JsonSerializerSettings jsonSerializerSettings; + + public JsonContentSerializer() : this(new JsonSerializerSettings()) + { + } + + public JsonContentSerializer(JsonSerializerSettings jsonSerializerSettings) + { + this.jsonSerializerSettings = jsonSerializerSettings ?? throw new ArgumentNullException(nameof(jsonSerializerSettings)); + } + + public HttpContent Serialize(object item) + { + return new StringContent(JsonConvert.SerializeObject(item, jsonSerializerSettings), Encoding.UTF8, "application/json"); + } + + public async Task DeserializeAsync(HttpContent content, Type objectType) + { + var serializer = JsonSerializer.Create(jsonSerializerSettings); + + using (var stream = await content.ReadAsStreamAsync().ConfigureAwait(false)) + using (var reader = new StreamReader(stream)) + using (var jsonTextReader = new JsonTextReader(reader)) + return serializer.Deserialize(jsonTextReader, objectType); + } + + public T Deserialize(string content) + { + var serializer = JsonSerializer.Create(jsonSerializerSettings); + + using (var reader = new StringReader(content)) + using (var jsonTextReader = new JsonTextReader(reader)) + return serializer.Deserialize(jsonTextReader); + } + } +} diff --git a/Refit/Refit.csproj b/Refit/Refit.csproj index 9fb8be1..38e8ff5 100644 --- a/Refit/Refit.csproj +++ b/Refit/Refit.csproj @@ -17,6 +17,7 @@ + diff --git a/Refit/RefitSettings.cs b/Refit/RefitSettings.cs index 5c17fbe..ffcde03 100644 --- a/Refit/RefitSettings.cs +++ b/Refit/RefitSettings.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Collections.Concurrent; using System.Globalization; using System.Linq; @@ -13,21 +12,43 @@ namespace Refit { public class RefitSettings { + JsonSerializerSettings jsonSerializerSettings; + public RefitSettings() { UrlParameterFormatter = new DefaultUrlParameterFormatter(); FormUrlEncodedParameterFormatter = new DefaultFormUrlEncodedParameterFormatter(); + ContentSerializer = new JsonContentSerializer(); } public Func> AuthorizationHeaderValueGetter { get; set; } public Func HttpMessageHandlerFactory { get; set; } - public JsonSerializerSettings JsonSerializerSettings { get; set; } + public JsonSerializerSettings JsonSerializerSettings + { + get => jsonSerializerSettings; + set + { + jsonSerializerSettings = value; + ContentSerializer = new JsonContentSerializer(value); + } + } + + public IContentSerializer ContentSerializer { get; set; } public IUrlParameterFormatter UrlParameterFormatter { get; set; } public IFormUrlEncodedParameterFormatter FormUrlEncodedParameterFormatter { get; set; } public bool Buffered { get; set; } = true; } + public interface IContentSerializer + { + HttpContent Serialize(object item); + + Task DeserializeAsync(HttpContent content, Type objectType); + + T Deserialize(string content); + } + public interface IUrlParameterFormatter { string Format(object value, ParameterInfo parameterInfo); diff --git a/Refit/RequestBuilderImplementation.cs b/Refit/RequestBuilderImplementation.cs index 6833e55..eb6a472 100644 --- a/Refit/RequestBuilderImplementation.cs +++ b/Refit/RequestBuilderImplementation.cs @@ -10,7 +10,6 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Web; -using Newtonsoft.Json; using System.Collections.Concurrent; using System.Net.Http.Headers; @@ -25,7 +24,7 @@ namespace Refit }; readonly Dictionary> interfaceHttpMethods; readonly ConcurrentDictionary interfaceGenericHttpMethods; - readonly JsonSerializer serializer; + readonly IContentSerializer serializer; readonly RefitSettings settings; public Type TargetType { get; } @@ -34,7 +33,7 @@ namespace Refit Type targetInterface = typeof(TApi); settings = refitSettings ?? new RefitSettings(); - serializer = JsonSerializer.Create(settings.JsonSerializerSettings); + serializer = settings.ContentSerializer; interfaceGenericHttpMethods = new ConcurrentDictionary(); if (targetInterface == null || !targetInterface.GetTypeInfo().IsInterface) @@ -201,8 +200,7 @@ namespace Refit Exception e = null; try { - var stringContent = new StringContent(JsonConvert.SerializeObject(itemValue, settings.JsonSerializerSettings), Encoding.UTF8, "application/json"); - multiPartContent.Add(stringContent, parameterName); + multiPartContent.Add(settings.ContentSerializer.Serialize(itemValue), parameterName); return; } catch(Exception ex) @@ -277,25 +275,26 @@ namespace Refit return (T)stream; } - using (var stream = await content.ReadAsStreamAsync().ConfigureAwait(false)) - using (var reader = new StreamReader(stream)) + if (serializedReturnType == typeof(string)) { - if (serializedReturnType == typeof(string)) + using(var stream = await content.ReadAsStreamAsync().ConfigureAwait(false)) + using(var reader = new StreamReader(stream)) { - var str = (object)await reader.ReadToEndAsync().ConfigureAwait(false); - if (isApiResponse) - return ApiResponse.Create(resp, str); - return (T)str; - } + if (serializedReturnType == typeof(string)) + { + var str = (object)await reader.ReadToEndAsync().ConfigureAwait(false); + if (isApiResponse) + return ApiResponse.Create(resp, str); + return (T)str; + } - using (var jsonReader = new JsonTextReader(reader)) - { - var json = serializer.Deserialize(jsonReader, serializedReturnType); - if (isApiResponse) - return ApiResponse.Create(resp, json); - return (T)json; } } + + var json = await serializer.DeserializeAsync(content, serializedReturnType); + if (isApiResponse) + return ApiResponse.Create(resp, json); + return (T)json; } finally { @@ -467,24 +466,15 @@ namespace Refit break; case BodySerializationMethod.Default: case BodySerializationMethod.Json: - var param = paramList[i]; + var content = serializer.Serialize(paramList[i]); switch (restMethod.BodyParameterInfo.Item2) { case false: - ret.Content = new PushStreamContent((stream, _, __) => - { - using (var writer = new JsonTextWriter(new StreamWriter(stream))) - { - serializer.Serialize(writer, param); - } - }, - new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }); + ret.Content = new PushStreamContent( + async (stream, _, __) => { await content.CopyToAsync(stream).ConfigureAwait(false); }, content.Headers.ContentType); break; case true: - ret.Content = new StringContent( - JsonConvert.SerializeObject(paramList[i], settings.JsonSerializerSettings), - Encoding.UTF8, - "application/json"); + ret.Content = content; break; } diff --git a/Refit/XmlContentSerializer.cs b/Refit/XmlContentSerializer.cs new file mode 100644 index 0000000..ecf92b1 --- /dev/null +++ b/Refit/XmlContentSerializer.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; + +namespace Refit { + + public class XmlContentSerializer : IContentSerializer + { + private readonly XmlContentSerializerSettings settings; + + public XmlContentSerializer() : this(new XmlContentSerializerSettings()) + { + } + + public XmlContentSerializer(XmlContentSerializerSettings settings) + { + this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public HttpContent Serialize(object item) + { + var xmlSerializer = new XmlSerializer(item.GetType(), settings.XmlAttributeOverrides); + + using(var output = new StringWriter()) + { + using(var writer = XmlWriter.Create(output, settings.XmlReaderWriterSettings.WriterSettings)) + { + xmlSerializer.Serialize(writer, item, settings.XmlNamespaces); + return new StringContent(output.ToString(), Encoding.UTF8, "application/xml"); + } + } + } + + public async Task DeserializeAsync(HttpContent content, Type objectType) + { + var xmlSerializer = new XmlSerializer(objectType, settings.XmlAttributeOverrides); + + using (var input = new StringReader(await content.ReadAsStringAsync().ConfigureAwait(false))) + { + using (var reader = XmlReader.Create(input, settings.XmlReaderWriterSettings.ReaderSettings)) + { + return xmlSerializer.Deserialize(reader); + } + } + } + + public T Deserialize(string content) + { + var xmlSerializer = new XmlSerializer(typeof(T), settings.XmlAttributeOverrides); + + using (var input = new StringReader(content)) + { + using (var reader = XmlReader.Create(input, settings.XmlReaderWriterSettings.ReaderSettings)) + { + return (T)(object)xmlSerializer.Deserialize(reader); + } + } + } + } + + public class XmlReaderWriterSettings + { + private XmlReaderSettings readerSettings; + private XmlWriterSettings writerSettings; + + public XmlReaderWriterSettings() : this(new XmlReaderSettings(), new XmlWriterSettings()) + { + } + + public XmlReaderWriterSettings(XmlReaderSettings readerSettings) : this(readerSettings, new XmlWriterSettings()) + { + } + + public XmlReaderWriterSettings(XmlWriterSettings writerSettings) : this(new XmlReaderSettings(), writerSettings) + { + } + + public XmlReaderWriterSettings(XmlReaderSettings readerSettings, XmlWriterSettings writerSettings) + { + ReaderSettings = readerSettings; + WriterSettings = writerSettings; + } + + public XmlReaderSettings ReaderSettings + { + get + { + ApplyOverrideSettings(); + return readerSettings; + } + set => readerSettings = value ?? throw new ArgumentNullException(nameof(value)); + } + + public XmlWriterSettings WriterSettings + { + get + { + ApplyOverrideSettings(); + return writerSettings; + } + set => writerSettings = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// The writer and reader settings are set by the caller, but certain properties + /// should remain set to meet the demands of the XmlContentSerializer. Those properties + /// are always set here. + /// + private void ApplyOverrideSettings() + { + writerSettings.Async = true; + readerSettings.Async = true; + } + } + + public class XmlContentSerializerSettings + { + public XmlContentSerializerSettings() + { + XmlReaderWriterSettings = new XmlReaderWriterSettings(); + XmlNamespaces = new XmlSerializerNamespaces( + new[] + { + new XmlQualifiedName(string.Empty, string.Empty), + }); + + XmlAttributeOverrides = new XmlAttributeOverrides(); + } + + public XmlReaderWriterSettings XmlReaderWriterSettings { get; set; } + + public XmlSerializerNamespaces XmlNamespaces { get; set; } + + public XmlAttributeOverrides XmlAttributeOverrides { get; set; } + } +}