From 7287873b3f87548bff0a1dabffc333398e2eeecc Mon Sep 17 00:00:00 2001 From: Mathews Bryan <37884221+jmbryan4@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:03:48 -0500 Subject: [PATCH 1/2] Replace Microsoft.AspNet.WebApi.Client with built-in formatter abstractions Remove the legacy Microsoft.AspNet.WebApi.Client dependency (end of support since 2019, no releases since 2020) and replace it with lightweight built-in abstractions: - New IMediaTypeFormatter interface replacing System.Net.Http.Formatting.MediaTypeFormatter - New MediaTypeFormatterCollection replacing the System.Net.Http.Formatting version - New JsonMediaTypeFormatter using Newtonsoft.Json directly - New XmlMediaTypeFormatter using DataContractSerializer - New FormatterContent replacing ObjectContent - New HttpContentExtensions.ReadAsAsync replacing the extension from System.Net.Http.Formatting Target frameworks updated from netstandard1.3/netstandard2.0/net452/net5.0 to netstandard2.0/net8.0. Version bumped to 5.0.0. Resolves #133. --- Client/Client.csproj | 17 +--- Client/FluentClient.cs | 4 +- Client/FluentClientExtensions.cs | 15 ++-- Client/Formatters/IMediaTypeFormatter.cs | 38 ++++++++ Client/Formatters/JsonMediaTypeFormatter.cs | 62 +++++++++++++ Client/Formatters/MediaTypeFormatterBase.cs | 87 ++++++++----------- .../MediaTypeFormatterCollection.cs | 22 +++++ Client/Formatters/PlainTextFormatter.cs | 8 +- Client/Formatters/XmlMediaTypeFormatter.cs | 81 +++++++++++++++++ Client/IBodyBuilder.cs | 4 +- Client/IClient.cs | 4 +- Client/IRequest.cs | 2 +- Client/IResponse.cs | 4 +- Client/Internal/BodyBuilder.cs | 10 +-- Client/Internal/Factory.cs | 12 +-- Client/Internal/FormatterContent.cs | 73 ++++++++++++++++ Client/Internal/HttpContentExtensions.cs | 48 ++++++++++ Client/Internal/LegacyShims.cs | 27 ------ Client/Internal/Request.cs | 2 +- Client/Internal/Response.cs | 10 +-- Client/Retry/RetryCoordinator.cs | 3 +- Tests/Client/RequestTests.cs | 23 ++--- Tests/Client/ResponseTests.cs | 13 +-- Tests/Formatters/FormatterTestsBase.cs | 27 ++---- Tests/Tests.csproj | 41 +-------- 25 files changed, 429 insertions(+), 208 deletions(-) create mode 100644 Client/Formatters/IMediaTypeFormatter.cs create mode 100644 Client/Formatters/JsonMediaTypeFormatter.cs create mode 100644 Client/Formatters/MediaTypeFormatterCollection.cs create mode 100644 Client/Formatters/XmlMediaTypeFormatter.cs create mode 100644 Client/Internal/FormatterContent.cs create mode 100644 Client/Internal/HttpContentExtensions.cs delete mode 100644 Client/Internal/LegacyShims.cs diff --git a/Client/Client.csproj b/Client/Client.csproj index f1bfdf4..8095a77 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -1,11 +1,11 @@ - netstandard1.3;netstandard2.0;net452;net5.0 + netstandard2.0;net8.0 Pathoschild.Http.Client Pathoschild.Http.Client Pathoschild.Http.FluentClient FluentHttpClient - 4.4.2 + 5.0.0 Pathoschild A modern async HTTP client for REST APIs. Its fluent interface lets you send an HTTP request and parse the response in one go. MIT @@ -14,26 +14,17 @@ git https://github.com/Pathoschild/FluentHttpClient.git README.md - See release notes at https://github.com/Pathoschild/FluentHttpClient/blob/develop/RELEASE-NOTES.md#442 + See release notes at https://github.com/Pathoschild/FluentHttpClient/blob/develop/RELEASE-NOTES.md#500 wcf;web;webapi;HttpClient;FluentHttp;FluentHttpClient true true latest enable - - - false - - - - - + - - diff --git a/Client/FluentClient.cs b/Client/FluentClient.cs index f099860..30586b2 100644 --- a/Client/FluentClient.cs +++ b/Client/FluentClient.cs @@ -4,11 +4,11 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; using System.Net.Http.Headers; using System.Reflection; using System.Threading.Tasks; using Pathoschild.Http.Client.Extensibility; +using Pathoschild.Http.Client.Formatters; using Pathoschild.Http.Client.Internal; using Pathoschild.Http.Client.Retry; @@ -44,7 +44,7 @@ public class FluentClient : IClient public HttpClient BaseClient { get; } /// - public MediaTypeFormatterCollection Formatters { get; } = []; + public MediaTypeFormatterCollection Formatters { get; } = [new JsonMediaTypeFormatter(), new XmlMediaTypeFormatter()]; /// public IRequestCoordinator? RequestCoordinator { get; private set; } diff --git a/Client/FluentClientExtensions.cs b/Client/FluentClientExtensions.cs index 1a0900e..a1554cb 100644 --- a/Client/FluentClientExtensions.cs +++ b/Client/FluentClientExtensions.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Pathoschild.Http.Client.Extensibility; +using Pathoschild.Http.Client.Formatters; using Pathoschild.Http.Client.Internal; using Pathoschild.Http.Client.Retry; @@ -303,7 +304,7 @@ internal static async Task CloneAsync(this HttpRequestMessag Version = request.Version }; -#if NET5_0_OR_GREATER +#if NET8_0_OR_GREATER foreach ((string key, object? value) in request.Options) clone.Options.Set(new HttpRequestOptionsKey(key), value); #else @@ -328,7 +329,7 @@ internal static async Task CloneAsync(this HttpRequestMessag Stream stream = new MemoryStream(); await content .CopyToAsync(stream -#if NET5_0_OR_GREATER +#if NET8_0_OR_GREATER , cancellationToken #endif ) @@ -362,23 +363,23 @@ await content UriBuilder builder = new(baseUrl); // special case: combine if either side is a fragment - if (!string.IsNullOrWhiteSpace(builder.Fragment) || resource.StartsWith('#')) + if (!string.IsNullOrWhiteSpace(builder.Fragment) || resource.StartsWith("#", StringComparison.Ordinal)) return new Uri(baseUrl + resource); // special case: if resource is a query string, validate and append it - if (resource.StartsWith('?') || resource.StartsWith('&')) + if (resource.StartsWith("?", StringComparison.Ordinal) || resource.StartsWith("&", StringComparison.Ordinal)) { bool baseHasQuery = !string.IsNullOrWhiteSpace(builder.Query); return baseHasQuery switch { - true when resource.StartsWith('?') => throw new FormatException($"Can't add resource name '{resource}' to base URL '{baseUrl}' because the latter already has a query string."), - false when resource.StartsWith('&') => throw new FormatException($"Can't add resource name '{resource}' to base URL '{baseUrl}' because the latter doesn't have a query string."), + true when resource.StartsWith("?", StringComparison.Ordinal) => throw new FormatException($"Can't add resource name '{resource}' to base URL '{baseUrl}' because the latter already has a query string."), + false when resource.StartsWith("&", StringComparison.Ordinal) => throw new FormatException($"Can't add resource name '{resource}' to base URL '{baseUrl}' because the latter doesn't have a query string."), _ => new Uri(baseUrl + resource) }; } // else make absolute URL - if (!builder.Path.EndsWith('/')) + if (!builder.Path.EndsWith("/", StringComparison.Ordinal)) { builder.Path += "/"; baseUrl = builder.Uri; diff --git a/Client/Formatters/IMediaTypeFormatter.cs b/Client/Formatters/IMediaTypeFormatter.cs new file mode 100644 index 0000000..d12c224 --- /dev/null +++ b/Client/Formatters/IMediaTypeFormatter.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Pathoschild.Http.Client.Formatters; + +/// Defines methods for serializing and deserializing HTTP message bodies. +public interface IMediaTypeFormatter +{ + /// The media types supported by this formatter. + IReadOnlyCollection SupportedMediaTypes { get; } + + /// Determines whether this formatter can deserialize an object of the specified type. + /// The type of object that will be deserialized. + bool CanReadType(Type type); + + /// Determines whether this formatter can serialize an object of the specified type. + /// The type of object that will be serialized. + bool CanWriteType(Type type); + + /// Reads an object from the stream asynchronously. + /// The type of object to read. + /// The stream from which to read. + /// The HTTP content being read. + /// The cancellation token. + Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken); + + /// Writes an object to the stream asynchronously. + /// The type of object to write. + /// The object instance to write. + /// The stream to which to write. + /// The HTTP content being written. + /// The cancellation token. + Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken); +} diff --git a/Client/Formatters/JsonMediaTypeFormatter.cs b/Client/Formatters/JsonMediaTypeFormatter.cs new file mode 100644 index 0000000..74580e6 --- /dev/null +++ b/Client/Formatters/JsonMediaTypeFormatter.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Pathoschild.Http.Client.Formatters; + +/// Serializes and deserializes data as JSON using Newtonsoft.Json. +public class JsonMediaTypeFormatter : IMediaTypeFormatter +{ + /********* + ** Accessors + *********/ + /// + public IReadOnlyCollection SupportedMediaTypes { get; } = ["application/json", "text/json"]; + + /// The JSON serializer settings. + public JsonSerializerSettings SerializerSettings { get; set; } = new(); + + + /********* + ** Public methods + *********/ + /// + public bool CanReadType(Type type) + { + return true; + } + + /// + public bool CanWriteType(Type type) + { + return true; + } + + /// + public Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) + { + JsonSerializer serializer = JsonSerializer.Create(this.SerializerSettings); + + using StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true); + using JsonTextReader jsonReader = new(reader); + object? result = serializer.Deserialize(jsonReader, type); + return Task.FromResult(result); + } + + /// + public Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) + { + JsonSerializer serializer = JsonSerializer.Create(this.SerializerSettings); + + using StreamWriter writer = new(stream, new UTF8Encoding(false), bufferSize: 1024, leaveOpen: true); + using JsonTextWriter jsonWriter = new(writer); + serializer.Serialize(jsonWriter, value, type); + jsonWriter.Flush(); + return Task.CompletedTask; + } +} diff --git a/Client/Formatters/MediaTypeFormatterBase.cs b/Client/Formatters/MediaTypeFormatterBase.cs index aaeb450..cdab862 100644 --- a/Client/Formatters/MediaTypeFormatterBase.cs +++ b/Client/Formatters/MediaTypeFormatterBase.cs @@ -1,52 +1,55 @@ using System; +using System.Collections.Generic; using System.IO; -using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; -using System.Net.Http.Headers; -using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Pathoschild.Http.Client.Formatters; -/// Base implementation of an HTTP for serialization providers. +/// Base implementation of an for serialization providers. /// This class handles the common code for implementing a media type formatter, so most subclasses only need to implement the and methods. -public abstract class MediaTypeFormatterBase : MediaTypeFormatter +public abstract class MediaTypeFormatterBase : IMediaTypeFormatter { + /********* + ** Fields + *********/ + /// The supported media types. + private readonly List MediaTypes = []; + + + /********* + ** Accessors + *********/ + /// + public IReadOnlyCollection SupportedMediaTypes => this.MediaTypes; + + /********* ** Public methods *********/ /*** ** Generic ***/ - /// Determines whether this can deserialize an object of the specified type. - /// The type of object that will be deserialized. - /// true if this can deserialize an object of that type; otherwise false. - public override bool CanReadType(Type type) + /// + public virtual bool CanReadType(Type type) { return true; } - /// Determines whether this can serialize an object of the specified type. - /// The type of object that will be serialized. - /// true if this can serialize an object of that type; otherwise false. - public override bool CanWriteType(Type type) + /// + public virtual bool CanWriteType(Type type) { return true; } - /// Reads an object from the stream asynchronously. - /// The type of object to read. - /// The stream from which to read. - /// The HTTP content being read. - /// The trace message logger. - /// A task which writes the object to the stream asynchronously. - public override Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, IFormatterLogger formatterLogger) + /// + public Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) { - var completionSource = new TaskCompletionSource(); + var completionSource = new TaskCompletionSource(); try { - object result = this.Deserialize(type, stream, content, formatterLogger); + object result = this.Deserialize(type, stream, content); completionSource.SetResult(result); } catch (Exception ex) @@ -57,19 +60,13 @@ public override Task ReadFromStreamAsync(Type type, Stream stream, HttpC return completionSource.Task; } - /// Writes an object to the stream asynchronously. - /// The type of object to write. - /// The object instance to write. - /// The stream to which to write. - /// The HTTP content being written. - /// The . - /// A task which writes the object to the stream asynchronously. - public override Task WriteToStreamAsync(Type type, object value, Stream stream, HttpContent content, TransportContext transportContext) + /// + public Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) { var completionSource = new TaskCompletionSource(); try { - this.Serialize(type, value, stream, content, transportContext); + this.Serialize(type, value, stream, content); completionSource.SetResult(null); } catch (Exception ex) @@ -82,13 +79,9 @@ public override Task WriteToStreamAsync(Type type, object value, Stream stream, /// Add a media type which can be read or written by this formatter. /// The media type string. - /// The relative quality factor. - public MediaTypeFormatterBase AddMediaType(string mediaType, double? quality = null) + public MediaTypeFormatterBase AddMediaType(string mediaType) { - this.SupportedMediaTypes.Add(quality.HasValue - ? new MediaTypeWithQualityHeaderValue(mediaType, quality.Value) - : new MediaTypeHeaderValue(mediaType) - ); + this.MediaTypes.Add(mediaType); return this; } @@ -99,25 +92,13 @@ public MediaTypeFormatterBase AddMediaType(string mediaType, double? quality = n /// The type of object to read. /// The stream from which to read. /// The HTTP content being read. - /// The trace message logger. /// Returns a deserialized object. - public abstract object Deserialize(Type type, Stream stream, HttpContent content, IFormatterLogger formatterLogger); + public abstract object Deserialize(Type type, Stream stream, HttpContent content); /// Serialize an object into the stream. /// The type of object to write. /// The object instance to write. /// The stream to which to write. /// The HTTP content being written. - /// The . - public abstract void Serialize(Type type, object? value, Stream stream, HttpContent content, TransportContext transportContext); - - - /********* - ** Protected methods - *********/ - /// Construct an instance. - protected MediaTypeFormatterBase() - { - this.SupportedEncodings.Add(new UTF8Encoding()); - } -} \ No newline at end of file + public abstract void Serialize(Type type, object? value, Stream stream, HttpContent content); +} diff --git a/Client/Formatters/MediaTypeFormatterCollection.cs b/Client/Formatters/MediaTypeFormatterCollection.cs new file mode 100644 index 0000000..738571c --- /dev/null +++ b/Client/Formatters/MediaTypeFormatterCollection.cs @@ -0,0 +1,22 @@ +using System.Collections.ObjectModel; +using System.Linq; + +namespace Pathoschild.Http.Client.Formatters; + +/// A collection of instances. +public class MediaTypeFormatterCollection : Collection +{ + /// Get the first formatter which supports the given media type. + /// The media type. + public IMediaTypeFormatter? FindReader(string mediaType) + { + return this.FirstOrDefault(f => f.SupportedMediaTypes.Any(m => m == mediaType) && f.CanReadType(typeof(object))); + } + + /// Get the first formatter which supports the given media type. + /// The media type. + public IMediaTypeFormatter? FindWriter(string mediaType) + { + return this.FirstOrDefault(f => f.SupportedMediaTypes.Any(m => m == mediaType) && f.CanWriteType(typeof(object))); + } +} diff --git a/Client/Formatters/PlainTextFormatter.cs b/Client/Formatters/PlainTextFormatter.cs index 98af322..11f6005 100644 --- a/Client/Formatters/PlainTextFormatter.cs +++ b/Client/Formatters/PlainTextFormatter.cs @@ -1,8 +1,6 @@ using System; using System.IO; -using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; using System.Reflection; namespace Pathoschild.Http.Client.Formatters; @@ -40,17 +38,17 @@ public override bool CanWriteType(Type type) } /// - public override object Deserialize(Type type, Stream stream, HttpContent content, IFormatterLogger formatterLogger) + public override object Deserialize(Type type, Stream stream, HttpContent content) { StreamReader reader = new(stream); // don't dispose (stream disposal is handled elsewhere) return reader.ReadToEnd(); } /// - public override void Serialize(Type type, object? value, Stream stream, HttpContent content, TransportContext transportContext) + public override void Serialize(Type type, object? value, Stream stream, HttpContent content) { StreamWriter writer = new(stream); // don't dispose (stream disposal is handled elsewhere) writer.Write(value != null ? value.ToString() : string.Empty); writer.Flush(); } -} \ No newline at end of file +} diff --git a/Client/Formatters/XmlMediaTypeFormatter.cs b/Client/Formatters/XmlMediaTypeFormatter.cs new file mode 100644 index 0000000..1ab59fa --- /dev/null +++ b/Client/Formatters/XmlMediaTypeFormatter.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; + +namespace Pathoschild.Http.Client.Formatters; + +/// Serializes and deserializes data as XML. Uses by default, or when is enabled. +public class XmlMediaTypeFormatter : IMediaTypeFormatter +{ + /********* + ** Accessors + *********/ + /// + public IReadOnlyCollection SupportedMediaTypes { get; } = ["application/xml", "text/xml"]; + + /// Whether to use instead of . + public bool UseXmlSerializer { get; set; } + + + /********* + ** Public methods + *********/ + /// + public bool CanReadType(Type type) + { + return true; + } + + /// + public bool CanWriteType(Type type) + { + return true; + } + + /// + public Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) + { + object? result; + + if (this.UseXmlSerializer) + { + XmlSerializer serializer = new(type); + result = serializer.Deserialize(stream); + } + else + { + DataContractSerializer serializer = new(type); + using XmlDictionaryReader reader = XmlDictionaryReader.CreateTextReader(stream, XmlDictionaryReaderQuotas.Max); + result = serializer.ReadObject(reader); + } + + return Task.FromResult(result); + } + + /// + public Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) + { + if (this.UseXmlSerializer) + { + XmlSerializer serializer = new(type); + using XmlWriter writer = XmlWriter.Create(stream, new XmlWriterSettings { CloseOutput = false }); + serializer.Serialize(writer, value); + writer.Flush(); + } + else + { + DataContractSerializer serializer = new(type); + using XmlWriter writer = XmlWriter.Create(stream, new XmlWriterSettings { CloseOutput = false }); + serializer.WriteObject(writer, value); + writer.Flush(); + } + + return Task.CompletedTask; + } +} diff --git a/Client/IBodyBuilder.cs b/Client/IBodyBuilder.cs index 2a0b833..3e0f5f8 100644 --- a/Client/IBodyBuilder.cs +++ b/Client/IBodyBuilder.cs @@ -3,8 +3,8 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Net.Http; -using System.Net.Http.Formatting; using System.Net.Http.Headers; +using Pathoschild.Http.Client.Formatters; namespace Pathoschild.Http.Client; @@ -69,5 +69,5 @@ public interface IBodyBuilder /// The media type formatter with which to format the request body format. /// The HTTP media type (or null for the 's default). /// Returns the request builder for chaining. - HttpContent Model(T body, MediaTypeFormatter formatter, string? mediaType = null); + HttpContent Model(T body, IMediaTypeFormatter formatter, string? mediaType = null); } diff --git a/Client/IClient.cs b/Client/IClient.cs index ba9bb7a..eb40c1c 100644 --- a/Client/IClient.cs +++ b/Client/IClient.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Net.Http; -using System.Net.Http.Formatting; using Pathoschild.Http.Client.Extensibility; +using Pathoschild.Http.Client.Formatters; using Pathoschild.Http.Client.Retry; namespace Pathoschild.Http.Client; @@ -56,4 +56,4 @@ public interface IClient : IDisposable /// Add a default behaviour for all subsequent HTTP requests. /// The default behaviour to apply. IClient AddDefault(Func apply); -} \ No newline at end of file +} diff --git a/Client/IRequest.cs b/Client/IRequest.cs index 099fb54..978de52 100644 --- a/Client/IRequest.cs +++ b/Client/IRequest.cs @@ -3,12 +3,12 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Net.Http; -using System.Net.Http.Formatting; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using Pathoschild.Http.Client.Extensibility; +using Pathoschild.Http.Client.Formatters; using Pathoschild.Http.Client.Retry; namespace Pathoschild.Http.Client; diff --git a/Client/IResponse.cs b/Client/IResponse.cs index 7176e89..fa63068 100644 --- a/Client/IResponse.cs +++ b/Client/IResponse.cs @@ -2,10 +2,10 @@ using System.IO; using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using Pathoschild.Http.Client.Formatters; namespace Pathoschild.Http.Client; @@ -74,4 +74,4 @@ public interface IResponse /// Get a raw JSON array representation of the response, which can also be accessed as a dynamic value. /// An error occurred processing the response. Task AsRawJsonArray(); -} \ No newline at end of file +} diff --git a/Client/Internal/BodyBuilder.cs b/Client/Internal/BodyBuilder.cs index 05f949f..70ad0e5 100644 --- a/Client/Internal/BodyBuilder.cs +++ b/Client/Internal/BodyBuilder.cs @@ -3,9 +3,9 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; using System.Net.Http.Headers; using System.Text; +using Pathoschild.Http.Client.Formatters; namespace Pathoschild.Http.Client.Internal; @@ -106,15 +106,15 @@ public HttpContent FileUpload(IEnumerable> files) /// public HttpContent Model(T body, MediaTypeHeaderValue? contentType = null) { - MediaTypeFormatter formatter = Factory.GetFormatter(this.Request.Formatters, contentType); + IMediaTypeFormatter formatter = Factory.GetFormatter(this.Request.Formatters, contentType); string? mediaType = contentType?.MediaType; - return new ObjectContent(body, formatter, mediaType); + return new FormatterContent(body, formatter, mediaType); } /// - public HttpContent Model(T body, MediaTypeFormatter formatter, string? mediaType = null) + public HttpContent Model(T body, IMediaTypeFormatter formatter, string? mediaType = null) { - return new ObjectContent(body, formatter, mediaType); + return new FormatterContent(body, formatter, mediaType); } diff --git a/Client/Internal/Factory.cs b/Client/Internal/Factory.cs index a2c58ec..8f47944 100644 --- a/Client/Internal/Factory.cs +++ b/Client/Internal/Factory.cs @@ -1,8 +1,8 @@ using System; using System.Linq; using System.Net.Http; -using System.Net.Http.Formatting; using System.Net.Http.Headers; +using Pathoschild.Http.Client.Formatters; namespace Pathoschild.Http.Client.Internal; @@ -19,13 +19,13 @@ internal static class Factory /// The formatters used for serializing and deserializing message bodies. /// The HTTP content type (or null to automatically select one). /// No MediaTypeFormatters are available on the API client for this content type. - public static MediaTypeFormatter GetFormatter(MediaTypeFormatterCollection formatters, MediaTypeHeaderValue? contentType = null) + public static IMediaTypeFormatter GetFormatter(MediaTypeFormatterCollection formatters, MediaTypeHeaderValue? contentType = null) { if (!formatters.Any()) throw new InvalidOperationException("No MediaTypeFormatters are available on the fluent client."); - MediaTypeFormatter? formatter = contentType != null - ? formatters.FirstOrDefault(f => f.SupportedMediaTypes.Any(m => m.MediaType == contentType.MediaType)) + IMediaTypeFormatter? formatter = contentType != null + ? formatters.FirstOrDefault(f => f.SupportedMediaTypes.Any(m => m == contentType.MediaType)) : formatters.FirstOrDefault(); if (formatter == null) throw new InvalidOperationException($"No MediaTypeFormatters are available on the fluent client for the '{contentType}' content-type."); @@ -42,8 +42,8 @@ public static HttpRequestMessage GetRequestMessage(HttpMethod method, Uri resour HttpRequestMessage request = new(method, resource); // add default headers - request.Headers.Add("accept", formatters.SelectMany(p => p.SupportedMediaTypes).Select(p => p.MediaType)); + request.Headers.Add("accept", formatters.SelectMany(p => p.SupportedMediaTypes)); return request; } -} \ No newline at end of file +} diff --git a/Client/Internal/FormatterContent.cs b/Client/Internal/FormatterContent.cs new file mode 100644 index 0000000..e52c221 --- /dev/null +++ b/Client/Internal/FormatterContent.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Pathoschild.Http.Client.Formatters; + +namespace Pathoschild.Http.Client.Internal; + +/// An that serializes an object using an . +/// The body type. +internal sealed class FormatterContent : HttpContent +{ + /********* + ** Fields + *********/ + /// The value to serialize. + private readonly T Value; + + /// The formatter to use for serialization. + private readonly IMediaTypeFormatter Formatter; + + /// The type to serialize as. + private readonly Type ObjectType; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The value to serialize. + /// The formatter to use. + /// The media type, or null for the formatter's first supported type. + public FormatterContent(T value, IMediaTypeFormatter formatter, string? mediaType = null) + : this(value, typeof(T), formatter, mediaType) { } + + /// Construct an instance with an explicit type. + /// The value to serialize. + /// The type to use for serialization. + /// The formatter to use. + /// The media type, or null for the formatter's first supported type. + public FormatterContent(T value, Type objectType, IMediaTypeFormatter formatter, string? mediaType = null) + { + if (!formatter.CanWriteType(objectType)) + throw new InvalidOperationException($"The configured formatter '{formatter.GetType().Name}' can't write content of type '{objectType.FullName}'."); + + this.Value = value; + this.Formatter = formatter; + this.ObjectType = objectType; + + string resolvedMediaType = mediaType ?? (formatter.SupportedMediaTypes.Count > 0 ? System.Linq.Enumerable.First(formatter.SupportedMediaTypes) : "application/octet-stream"); + this.Headers.ContentType = new MediaTypeHeaderValue(resolvedMediaType); + } + + + /********* + ** Protected methods + *********/ + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + await this.Formatter.WriteToStreamAsync(this.ObjectType, this.Value, stream, this, CancellationToken.None).ConfigureAwait(false); + } + + /// + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } +} diff --git a/Client/Internal/HttpContentExtensions.cs b/Client/Internal/HttpContentExtensions.cs new file mode 100644 index 0000000..b7c821e --- /dev/null +++ b/Client/Internal/HttpContentExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Pathoschild.Http.Client.Formatters; + +namespace Pathoschild.Http.Client.Internal; + +/// Extension methods for . +internal static class HttpContentExtensions +{ + /// Read the HTTP content as a deserialized model. + /// The model type. + /// The HTTP content to read. + /// The formatters to use for deserialization. + /// The cancellation token. + public static async Task ReadAsAsync(this HttpContent content, MediaTypeFormatterCollection formatters, CancellationToken cancellationToken = default) + { + string? mediaType = content.Headers.ContentType?.MediaType; + + // find a matching formatter + IMediaTypeFormatter? formatter = mediaType != null + ? formatters.FirstOrDefault(f => f.SupportedMediaTypes.Any(m => m == mediaType) && f.CanReadType(typeof(T))) + : null; + + // fall back to first formatter that can read this type + formatter ??= formatters.FirstOrDefault(f => f.CanReadType(typeof(T))); + + if (formatter == null) + throw new InvalidOperationException($"No MediaTypeFormatter is available to read type '{typeof(T).FullName}' from content with media type '{mediaType}'."); + + Stream stream = await content + .ReadAsStreamAsync( +#if NET8_0_OR_GREATER + cancellationToken +#endif + ) + .ConfigureAwait(false); + + if (stream.CanSeek) + stream.Position = 0; + + object? result = await formatter.ReadFromStreamAsync(typeof(T), stream, content, cancellationToken).ConfigureAwait(false); + return (T)result!; + } +} diff --git a/Client/Internal/LegacyShims.cs b/Client/Internal/LegacyShims.cs deleted file mode 100644 index 10eb3e1..0000000 --- a/Client/Internal/LegacyShims.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ReSharper disable once CheckNamespace -- deliberate to make it available without an explicit namespace import -namespace Pathoschild.Http.Client; - -/// Wraps newer .NET features that improve performance, but aren't available on older platforms. -internal static class LegacyShims -{ - /********* - ** Strings - *********/ -#if !NET5_0_OR_GREATER - /// Get whether the first character of the string is the given character. - /// The string to search. - /// The character to find. - public static bool StartsWith(this string value, char ch) - { - return value.Length > 0 && value[0] == ch; - } - - /// Get whether the last character of the string is the given character. - /// The string to search. - /// The character to find. - public static bool EndsWith(this string value, char ch) - { - return value.Length > 0 && value[value.Length - 1] == ch; - } -#endif -} diff --git a/Client/Internal/Request.cs b/Client/Internal/Request.cs index 98d79e4..f575090 100644 --- a/Client/Internal/Request.cs +++ b/Client/Internal/Request.cs @@ -3,13 +3,13 @@ using System.IO; using System.Linq; using System.Net.Http; -using System.Net.Http.Formatting; using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using Pathoschild.Http.Client.Extensibility; +using Pathoschild.Http.Client.Formatters; using Pathoschild.Http.Client.Retry; namespace Pathoschild.Http.Client.Internal; diff --git a/Client/Internal/Response.cs b/Client/Internal/Response.cs index 4e84514..4829244 100644 --- a/Client/Internal/Response.cs +++ b/Client/Internal/Response.cs @@ -3,10 +3,10 @@ using System.IO; using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using Pathoschild.Http.Client.Formatters; namespace Pathoschild.Http.Client.Internal; @@ -69,7 +69,7 @@ public Task AsArray() public Task AsByteArray() { return this.AssertContent().ReadAsByteArrayAsync( -#if NET5_0_OR_GREATER +#if NET8_0_OR_GREATER this.CancellationToken #endif ); @@ -79,7 +79,7 @@ public Task AsByteArray() public Task AsString() { return this.AssertContent().ReadAsStringAsync( -#if NET5_0_OR_GREATER +#if NET8_0_OR_GREATER this.CancellationToken #endif ); @@ -90,7 +90,7 @@ public async Task AsStream() { Stream stream = await this.AssertContent() .ReadAsStreamAsync( -#if NET5_0_OR_GREATER +#if NET8_0_OR_GREATER this.CancellationToken #endif ) @@ -129,7 +129,7 @@ public async Task AsRawJsonArray() *********/ /// Assert that the response has a body. /// The response has no response body to read. -#if NET5_0_OR_GREATER +#if NET8_0_OR_GREATER [MemberNotNull(nameof(Message))] #endif private HttpContent AssertContent() diff --git a/Client/Retry/RetryCoordinator.cs b/Client/Retry/RetryCoordinator.cs index 9c903b0..116b2b6 100644 --- a/Client/Retry/RetryCoordinator.cs +++ b/Client/Retry/RetryCoordinator.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Pathoschild.Http.Client.Formatters; using Pathoschild.Http.Client.Internal; namespace Pathoschild.Http.Client.Retry; @@ -68,7 +69,7 @@ public async Task ExecuteAsync(IRequest request, Func("argument", body) -#else - new KeyValuePair("argument", body) -#endif })); // assert @@ -434,7 +426,7 @@ public async Task WithBody_HttpContent(string methodName, string body) public async Task WithBody_Builder_HttpContent(string methodName, object body) { // arrange - HttpContent content = new ObjectContent(typeof(string), body, new JsonMediaTypeFormatter()); + HttpContent content = new FormatterContent(body, new JsonMediaTypeFormatter()); // act IRequest request = this @@ -539,7 +531,7 @@ public async Task WithCustom(string methodName, string customBody) // act IRequest request = this .ConstructRequest(methodName) - .WithCustom(r => r.Content = new ObjectContent(customBody, new JsonMediaTypeFormatter())); + .WithCustom(r => r.Content = new FormatterContent(customBody, new JsonMediaTypeFormatter())); // assert this.AssertEqual(request.Message, methodName, ignoreArguments: true); @@ -813,7 +805,8 @@ private IRequest ConstructRequest(string methodName, string uri = "http://exampl HttpRequestMessage message = new(method, uri); // act - IRequest request = new Request(message, [], _ => new Task(() => new HttpResponseMessage(HttpStatusCode.OK)), []); + MediaTypeFormatterCollection formatters = [new JsonMediaTypeFormatter()]; + IRequest request = new Request(message, formatters, _ => new Task(() => new HttpResponseMessage(HttpStatusCode.OK)), []); // assert this.AssertEqual(request.Message, method, uri); @@ -833,7 +826,8 @@ private IRequest ConstructRequest(string methodName, string uri = "http://exampl private IRequest ConstructResponseFromTask(Task task) { HttpRequestMessage request = new(HttpMethod.Get, "http://example.org/"); - return new Request(request, [], _ => task, []); + MediaTypeFormatterCollection formatters = [new JsonMediaTypeFormatter()]; + return new Request(request, formatters, _ => task, []); } /// Construct an instance around an asynchronous task. @@ -841,7 +835,8 @@ private IRequest ConstructResponseFromTask(Task task) private IRequest ConstructResponseFromTask(Func task) { HttpRequestMessage request = new(HttpMethod.Get, "http://example.org/"); - return new Request(request, [], _ => Task.Factory.StartNew(task), []); + MediaTypeFormatterCollection formatters = [new JsonMediaTypeFormatter()]; + return new Request(request, formatters, _ => Task.Factory.StartNew(task), []); } /// Assert that an HTTP request's state matches the expected values. diff --git a/Tests/Client/ResponseTests.cs b/Tests/Client/ResponseTests.cs index 100efe5..fde2fdc 100644 --- a/Tests/Client/ResponseTests.cs +++ b/Tests/Client/ResponseTests.cs @@ -1,12 +1,12 @@ using System.IO; using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; using System.Text; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using NUnit.Framework; using Pathoschild.Http.Client; +using Pathoschild.Http.Client.Formatters; using Pathoschild.Http.Client.Internal; namespace Pathoschild.Http.Tests.Client; @@ -147,7 +147,9 @@ public async Task AsArray(string contentA, string contentB) .VerifyTaskResultAsync(); // assert - Assert.That(actual, Is.EquivalentTo(expected)); + Assert.That(actual.Length, Is.EqualTo(expected.Length)); + for (int i = 0; i < expected.Length; i++) + Assert.That(actual[i].Value, Is.EqualTo(expected[i].Value)); } /**** @@ -402,9 +404,10 @@ private IResponse ConstructResponse(T content, out HttpResponseMessage respon { // construct response HttpRequestMessage requestMessage = new(new HttpMethod(method), uri); - responseMessage = requestMessage.CreateResponse(status); - responseMessage.Content = new ObjectContent(content, new JsonMediaTypeFormatter()); - IResponse response = new Response(responseMessage, []); + responseMessage = new HttpResponseMessage(status) { RequestMessage = requestMessage }; + responseMessage.Content = new FormatterContent(content, new JsonMediaTypeFormatter()); + MediaTypeFormatterCollection formatters = [new JsonMediaTypeFormatter()]; + IResponse response = new Response(responseMessage, formatters); // verify this.AssertEqual(responseMessage.RequestMessage, method, uri); diff --git a/Tests/Formatters/FormatterTestsBase.cs b/Tests/Formatters/FormatterTestsBase.cs index ea8dff2..cf6a67c 100644 --- a/Tests/Formatters/FormatterTestsBase.cs +++ b/Tests/Formatters/FormatterTestsBase.cs @@ -1,28 +1,15 @@ using System; using System.IO; -using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; using System.Net.Http.Headers; using Pathoschild.Http.Client.Formatters; +using Pathoschild.Http.Client.Internal; namespace Pathoschild.Http.Tests.Formatters; /// Provides generic helper methods for unit tests. public abstract class FormatterTestsBase { - /********* - ** Fields - *********/ - /// A null transport context. - /// has the transport context marked as non-nullable, but we know the default implementations tested here allow null values. Since we can't easily construct a transport context, this field returns a null transport context marked as non-nullable. - private readonly TransportContext NullTransportContext = null!; - - /// A null formatter logger. - /// See remarks on . - private readonly IFormatterLogger FormatterLogger = null!; - - /********* ** Protected methods *********/ @@ -31,7 +18,7 @@ public abstract class FormatterTestsBase /// The request body content. /// The formatter with which the content can be serialized. /// The HTTP Accept and Content-Type header values. - protected HttpRequestMessage GetRequest(T content, MediaTypeFormatter formatter, string? contentType = null) + protected HttpRequestMessage GetRequest(T content, IMediaTypeFormatter formatter, string? contentType = null) { if (content == null) throw new ArgumentNullException(nameof(content)); @@ -44,11 +31,11 @@ protected HttpRequestMessage GetRequest(T content, MediaTypeFormatter formatt /// The formatter with which the content can be serialized. /// The object type of the . /// The HTTP Accept and Content-Type header values. - protected HttpRequestMessage GetRequest(object content, MediaTypeFormatter formatter, Type type, string? contentType = null) + protected HttpRequestMessage GetRequest(object content, IMediaTypeFormatter formatter, Type type, string? contentType = null) { HttpRequestMessage message = new(HttpMethod.Get, "http://example.org") { - Content = new ObjectContent(type, content, formatter) + Content = new FormatterContent(content, type, formatter) }; if (contentType != null) { @@ -71,7 +58,7 @@ protected string GetSerialized(T content, HttpRequestMessage request, MediaTy using MemoryStream stream = new(); using StreamReader reader = new(stream); - formatter.Serialize(typeof(string), content, stream, request.Content, this.NullTransportContext); + formatter.Serialize(typeof(string), content, stream, request.Content); stream.Position = 0; return reader.ReadToEnd(); } @@ -95,6 +82,6 @@ protected object GetDeserialized(Type type, string? content, HttpRequestMessage stream.Position = 0; // deserialize - return formatter.Deserialize(type, stream, request.Content, this.FormatterLogger); + return formatter.Deserialize(type, stream, request.Content); } -} \ No newline at end of file +} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 3910c73..a97d923 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -1,21 +1,12 @@ - netcoreapp1.1;netcoreapp2.0;net452;net5.0 + net8.0 Pathoschild.Http.Tests Pathoschild.Http.Tests true - $(PackageTargetFallback);dnxcore50 latest enable - - - false - - - - false - true @@ -23,36 +14,12 @@ - - + - - + + - - - - - From 3a6180b5d2c44b7cfa5e6a127e3a6692e08477b7 Mon Sep 17 00:00:00 2001 From: Mathews Bryan <37884221+jmbryan4@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:43:33 -0500 Subject: [PATCH 2/2] Rewrite formatters with async-first I/O and add formatter test coverage - Make MediaTypeFormatterBase.ReadFromStreamAsync/WriteToStreamAsync virtual and replace TaskCompletionSource anti-pattern with Task.FromResult/CompletedTask - Rewrite JsonMediaTypeFormatter with async stream read/write via ReadToEndAsync + JsonConvert and Stream.WriteAsync - Rewrite XmlMediaTypeFormatter with async CopyToAsync buffering strategy - Add async overrides to PlainTextFormatter using ReadToEndAsync/WriteAsync and fix leaveOpen:true on StreamReader/StreamWriter constructors - Add CancellationToken-accepting SerializeToStreamAsync override to FormatterContent on net8.0 - Flow CancellationToken on net8.0 throughout all async formatter methods - Add 55 new tests covering JsonMediaTypeFormatter, XmlMediaTypeFormatter, MediaTypeFormatterCollection, FormatterContent, HttpContentExtensions, and expanded PlainTextFormatter coverage --- Client/Formatters/JsonMediaTypeFormatter.cs | 31 +-- Client/Formatters/MediaTypeFormatterBase.cs | 32 +-- Client/Formatters/PlainTextFormatter.cs | 38 +++- Client/Formatters/XmlMediaTypeFormatter.cs | 39 +++- Client/Internal/FormatterContent.cs | 11 +- .../Formatters/JsonMediaTypeFormatterTests.cs | 201 ++++++++++++++++++ .../MediaTypeFormatterCollectionTests.cs | 80 +++++++ Tests/Formatters/PlainTextFormatter.cs | 65 ++++++ .../Formatters/XmlMediaTypeFormatterTests.cs | 184 ++++++++++++++++ Tests/Internal/FormatterContentTests.cs | 107 ++++++++++ Tests/Internal/HttpContentExtensionsTests.cs | 93 ++++++++ 11 files changed, 827 insertions(+), 54 deletions(-) create mode 100644 Tests/Formatters/JsonMediaTypeFormatterTests.cs create mode 100644 Tests/Formatters/MediaTypeFormatterCollectionTests.cs create mode 100644 Tests/Formatters/XmlMediaTypeFormatterTests.cs create mode 100644 Tests/Internal/FormatterContentTests.cs create mode 100644 Tests/Internal/HttpContentExtensionsTests.cs diff --git a/Client/Formatters/JsonMediaTypeFormatter.cs b/Client/Formatters/JsonMediaTypeFormatter.cs index 74580e6..4e59dfb 100644 --- a/Client/Formatters/JsonMediaTypeFormatter.cs +++ b/Client/Formatters/JsonMediaTypeFormatter.cs @@ -38,25 +38,28 @@ public bool CanWriteType(Type type) } /// - public Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) + public async Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) { - JsonSerializer serializer = JsonSerializer.Create(this.SerializerSettings); - using StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true); - using JsonTextReader jsonReader = new(reader); - object? result = serializer.Deserialize(jsonReader, type); - return Task.FromResult(result); + string json = await reader.ReadToEndAsync( +#if NET8_0_OR_GREATER + cancellationToken +#endif + ).ConfigureAwait(false); + return JsonConvert.DeserializeObject(json, type, this.SerializerSettings); } /// - public Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) + public async Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) { - JsonSerializer serializer = JsonSerializer.Create(this.SerializerSettings); - - using StreamWriter writer = new(stream, new UTF8Encoding(false), bufferSize: 1024, leaveOpen: true); - using JsonTextWriter jsonWriter = new(writer); - serializer.Serialize(jsonWriter, value, type); - jsonWriter.Flush(); - return Task.CompletedTask; + string json = JsonConvert.SerializeObject(value, type, this.SerializerSettings); + byte[] bytes = new UTF8Encoding(false).GetBytes(json); + await stream.WriteAsync( +#if NET8_0_OR_GREATER + bytes.AsMemory(), cancellationToken +#else + bytes, 0, bytes.Length, cancellationToken +#endif + ).ConfigureAwait(false); } } diff --git a/Client/Formatters/MediaTypeFormatterBase.cs b/Client/Formatters/MediaTypeFormatterBase.cs index cdab862..8d4fa1c 100644 --- a/Client/Formatters/MediaTypeFormatterBase.cs +++ b/Client/Formatters/MediaTypeFormatterBase.cs @@ -44,37 +44,17 @@ public virtual bool CanWriteType(Type type) } /// - public Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) + public virtual Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) { - var completionSource = new TaskCompletionSource(); - try - { - object result = this.Deserialize(type, stream, content); - completionSource.SetResult(result); - } - catch (Exception ex) - { - completionSource.SetException(ex); - } - - return completionSource.Task; + object result = this.Deserialize(type, stream, content); + return Task.FromResult(result); } /// - public Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) + public virtual Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) { - var completionSource = new TaskCompletionSource(); - try - { - this.Serialize(type, value, stream, content); - completionSource.SetResult(null); - } - catch (Exception ex) - { - completionSource.SetException(ex); - } - - return completionSource.Task; + this.Serialize(type, value, stream, content); + return Task.CompletedTask; } /// Add a media type which can be read or written by this formatter. diff --git a/Client/Formatters/PlainTextFormatter.cs b/Client/Formatters/PlainTextFormatter.cs index 11f6005..db81624 100644 --- a/Client/Formatters/PlainTextFormatter.cs +++ b/Client/Formatters/PlainTextFormatter.cs @@ -2,6 +2,9 @@ using System.IO; using System.Net.Http; using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; namespace Pathoschild.Http.Client.Formatters; @@ -40,15 +43,46 @@ public override bool CanWriteType(Type type) /// public override object Deserialize(Type type, Stream stream, HttpContent content) { - StreamReader reader = new(stream); // don't dispose (stream disposal is handled elsewhere) + StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true); return reader.ReadToEnd(); } /// public override void Serialize(Type type, object? value, Stream stream, HttpContent content) { - StreamWriter writer = new(stream); // don't dispose (stream disposal is handled elsewhere) + StreamWriter writer = new(stream, new UTF8Encoding(false), bufferSize: 1024, leaveOpen: true); writer.Write(value != null ? value.ToString() : string.Empty); writer.Flush(); } + + /// + public override async Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) + { + StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true); + string result = await reader.ReadToEndAsync( +#if NET8_0_OR_GREATER + cancellationToken +#endif + ).ConfigureAwait(false); + return result; + } + + /// + public override async Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) + { + StreamWriter writer = new(stream, new UTF8Encoding(false), bufferSize: 1024, leaveOpen: true); + string text = value != null ? value.ToString()! : string.Empty; + await writer.WriteAsync( +#if NET8_0_OR_GREATER + text.AsMemory(), cancellationToken +#else + text +#endif + ).ConfigureAwait(false); + await writer.FlushAsync( +#if NET8_0_OR_GREATER + cancellationToken +#endif + ).ConfigureAwait(false); + } } diff --git a/Client/Formatters/XmlMediaTypeFormatter.cs b/Client/Formatters/XmlMediaTypeFormatter.cs index 1ab59fa..ced46ba 100644 --- a/Client/Formatters/XmlMediaTypeFormatter.cs +++ b/Client/Formatters/XmlMediaTypeFormatter.cs @@ -39,43 +39,60 @@ public bool CanWriteType(Type type) } /// - public Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) + public async Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) { - object? result; + // buffer the input stream asynchronously, then deserialize synchronously + using MemoryStream buffer = new(); + await stream.CopyToAsync( +#if NET8_0_OR_GREATER + buffer, cancellationToken +#else + buffer +#endif + ).ConfigureAwait(false); + buffer.Position = 0; if (this.UseXmlSerializer) { XmlSerializer serializer = new(type); - result = serializer.Deserialize(stream); + return serializer.Deserialize(buffer); } else { DataContractSerializer serializer = new(type); - using XmlDictionaryReader reader = XmlDictionaryReader.CreateTextReader(stream, XmlDictionaryReaderQuotas.Max); - result = serializer.ReadObject(reader); + using XmlDictionaryReader reader = XmlDictionaryReader.CreateTextReader(buffer, XmlDictionaryReaderQuotas.Max); + return serializer.ReadObject(reader); } - - return Task.FromResult(result); } /// - public Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) + public async Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) { + // serialize synchronously into a buffer, then copy to output stream asynchronously + using MemoryStream buffer = new(); + if (this.UseXmlSerializer) { XmlSerializer serializer = new(type); - using XmlWriter writer = XmlWriter.Create(stream, new XmlWriterSettings { CloseOutput = false }); + using XmlWriter writer = XmlWriter.Create(buffer, new XmlWriterSettings { CloseOutput = false }); serializer.Serialize(writer, value); writer.Flush(); } else { DataContractSerializer serializer = new(type); - using XmlWriter writer = XmlWriter.Create(stream, new XmlWriterSettings { CloseOutput = false }); + using XmlWriter writer = XmlWriter.Create(buffer, new XmlWriterSettings { CloseOutput = false }); serializer.WriteObject(writer, value); writer.Flush(); } - return Task.CompletedTask; + buffer.Position = 0; + await buffer.CopyToAsync( +#if NET8_0_OR_GREATER + stream, cancellationToken +#else + stream +#endif + ).ConfigureAwait(false); } } diff --git a/Client/Internal/FormatterContent.cs b/Client/Internal/FormatterContent.cs index e52c221..473c6ce 100644 --- a/Client/Internal/FormatterContent.cs +++ b/Client/Internal/FormatterContent.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -50,7 +51,7 @@ public FormatterContent(T value, Type objectType, IMediaTypeFormatter formatter, this.Formatter = formatter; this.ObjectType = objectType; - string resolvedMediaType = mediaType ?? (formatter.SupportedMediaTypes.Count > 0 ? System.Linq.Enumerable.First(formatter.SupportedMediaTypes) : "application/octet-stream"); + string resolvedMediaType = mediaType ?? (formatter.SupportedMediaTypes.Count > 0 ? formatter.SupportedMediaTypes.First() : "application/octet-stream"); this.Headers.ContentType = new MediaTypeHeaderValue(resolvedMediaType); } @@ -64,6 +65,14 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon await this.Formatter.WriteToStreamAsync(this.ObjectType, this.Value, stream, this, CancellationToken.None).ConfigureAwait(false); } +#if NET8_0_OR_GREATER + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + await this.Formatter.WriteToStreamAsync(this.ObjectType, this.Value, stream, this, cancellationToken).ConfigureAwait(false); + } +#endif + /// protected override bool TryComputeLength(out long length) { diff --git a/Tests/Formatters/JsonMediaTypeFormatterTests.cs b/Tests/Formatters/JsonMediaTypeFormatterTests.cs new file mode 100644 index 0000000..357b072 --- /dev/null +++ b/Tests/Formatters/JsonMediaTypeFormatterTests.cs @@ -0,0 +1,201 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using NUnit.Framework; +using Pathoschild.Http.Client.Formatters; + +namespace Pathoschild.Http.Tests.Formatters; + +/// Unit tests verifying that the correctly serializes and deserializes content. +[TestFixture] +public class JsonMediaTypeFormatterTests +{ + /********* + ** Unit tests + *********/ + /**** + ** SupportedMediaTypes + ****/ + [Test(Description = "Ensure that the formatter supports 'application/json'.")] + public void SupportedMediaTypes_ContainsApplicationJson() + { + JsonMediaTypeFormatter formatter = new(); + Assert.That(formatter.SupportedMediaTypes, Does.Contain("application/json")); + } + + [Test(Description = "Ensure that the formatter supports 'text/json'.")] + public void SupportedMediaTypes_ContainsTextJson() + { + JsonMediaTypeFormatter formatter = new(); + Assert.That(formatter.SupportedMediaTypes, Does.Contain("text/json")); + } + + /**** + ** CanReadType / CanWriteType + ****/ + [Test(Description = "Ensure that CanReadType returns true for all types.")] + [TestCase(typeof(string))] + [TestCase(typeof(int))] + [TestCase(typeof(SampleModel))] + public void CanReadType_ReturnsTrue(Type type) + { + JsonMediaTypeFormatter formatter = new(); + Assert.That(formatter.CanReadType(type), Is.True); + } + + [Test(Description = "Ensure that CanWriteType returns true for all types.")] + [TestCase(typeof(string))] + [TestCase(typeof(int))] + [TestCase(typeof(SampleModel))] + public void CanWriteType_ReturnsTrue(Type type) + { + JsonMediaTypeFormatter formatter = new(); + Assert.That(formatter.CanWriteType(type), Is.True); + } + + /**** + ** Round-trip serialization + ****/ + [Test(Description = "Ensure that a string value round-trips correctly.")] + [TestCase("hello")] + [TestCase("")] + [TestCase("with \"quotes\" and \\slashes")] + public async Task RoundTrip_String(string value) + { + JsonMediaTypeFormatter formatter = new(); + object? result = await this.RoundTrip(value, typeof(string), formatter); + Assert.That(result, Is.EqualTo(value)); + } + + [Test(Description = "Ensure that integer values round-trip correctly.")] + [TestCase(0)] + [TestCase(42)] + [TestCase(-1)] + public async Task RoundTrip_Int(int value) + { + JsonMediaTypeFormatter formatter = new(); + object? result = await this.RoundTrip(value, typeof(int), formatter); + Assert.That(result, Is.EqualTo(value)); + } + + [Test(Description = "Ensure that boolean values round-trip correctly.")] + [TestCase(true)] + [TestCase(false)] + public async Task RoundTrip_Bool(bool value) + { + JsonMediaTypeFormatter formatter = new(); + object? result = await this.RoundTrip(value, typeof(bool), formatter); + Assert.That(result, Is.EqualTo(value)); + } + + [Test(Description = "Ensure that a complex object round-trips correctly.")] + public async Task RoundTrip_ComplexObject() + { + // arrange + JsonMediaTypeFormatter formatter = new(); + SampleModel original = new() { Id = 42, Name = "test", Nested = new SampleNested { Value = "inner" } }; + + // act + object? result = await this.RoundTrip(original, typeof(SampleModel), formatter); + + // assert + SampleModel deserialized = (SampleModel)result!; + Assert.That(deserialized.Id, Is.EqualTo(42)); + Assert.That(deserialized.Name, Is.EqualTo("test")); + Assert.That(deserialized.Nested, Is.Not.Null); + Assert.That(deserialized.Nested!.Value, Is.EqualTo("inner")); + } + + [Test(Description = "Ensure that null values round-trip correctly.")] + public async Task RoundTrip_Null() + { + JsonMediaTypeFormatter formatter = new(); + + using MemoryStream stream = new(); + using StringContent content = new(""); + + await formatter.WriteToStreamAsync(typeof(SampleModel), null, stream, content, CancellationToken.None); + stream.Position = 0; + + object? result = await formatter.ReadFromStreamAsync(typeof(SampleModel), stream, content, CancellationToken.None); + Assert.That(result, Is.Null); + } + + /**** + ** Custom settings + ****/ + [Test(Description = "Ensure that custom JsonSerializerSettings are applied.")] + public async Task CustomSettings_NullValueHandlingIgnore() + { + // arrange + JsonMediaTypeFormatter formatter = new() + { + SerializerSettings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore } + }; + SampleModel model = new() { Id = 1, Name = null }; + + // act + using MemoryStream stream = new(); + using StringContent content = new(""); + await formatter.WriteToStreamAsync(typeof(SampleModel), model, stream, content, CancellationToken.None); + + // assert + stream.Position = 0; + string json = new StreamReader(stream).ReadToEnd(); + Assert.That(json, Does.Not.Contain("Name")); + } + + /**** + ** Error handling + ****/ + [Test(Description = "Ensure that deserializing invalid JSON throws.")] + public void ReadFromStreamAsync_InvalidJson_Throws() + { + // arrange + JsonMediaTypeFormatter formatter = new(); + byte[] bytes = Encoding.UTF8.GetBytes("not valid json {{{"); + using MemoryStream stream = new(bytes); + using StringContent content = new(""); + + // act & assert + Assert.ThrowsAsync(async () => + await formatter.ReadFromStreamAsync(typeof(SampleModel), stream, content, CancellationToken.None) + ); + } + + + /********* + ** Private methods + *********/ + /// Serialize then deserialize a value through the formatter. + private async Task RoundTrip(object value, Type type, JsonMediaTypeFormatter formatter) + { + using MemoryStream stream = new(); + using StringContent content = new(""); + + await formatter.WriteToStreamAsync(type, value, stream, content, CancellationToken.None); + stream.Position = 0; + return await formatter.ReadFromStreamAsync(type, stream, content, CancellationToken.None); + } + + + /********* + ** Test models + *********/ + public class SampleModel + { + public int Id { get; set; } + public string? Name { get; set; } + public SampleNested? Nested { get; set; } + } + + public class SampleNested + { + public string? Value { get; set; } + } +} diff --git a/Tests/Formatters/MediaTypeFormatterCollectionTests.cs b/Tests/Formatters/MediaTypeFormatterCollectionTests.cs new file mode 100644 index 0000000..0cd00d9 --- /dev/null +++ b/Tests/Formatters/MediaTypeFormatterCollectionTests.cs @@ -0,0 +1,80 @@ +using NUnit.Framework; +using Pathoschild.Http.Client.Formatters; + +namespace Pathoschild.Http.Tests.Formatters; + +/// Unit tests verifying that correctly finds formatters. +[TestFixture] +public class MediaTypeFormatterCollectionTests +{ + /********* + ** Unit tests + *********/ + /**** + ** FindReader + ****/ + [Test(Description = "Ensure that FindReader returns a matching formatter by media type.")] + public void FindReader_ReturnsMatchingFormatter() + { + // arrange + MediaTypeFormatterCollection collection = []; + JsonMediaTypeFormatter jsonFormatter = new(); + XmlMediaTypeFormatter xmlFormatter = new(); + collection.Add(jsonFormatter); + collection.Add(xmlFormatter); + + // act + IMediaTypeFormatter? result = collection.FindReader("application/json"); + + // assert + Assert.That(result, Is.SameAs(jsonFormatter)); + } + + [Test(Description = "Ensure that FindReader returns null when no formatter matches.")] + public void FindReader_ReturnsNull_WhenNoMatch() + { + // arrange + MediaTypeFormatterCollection collection = []; + collection.Add(new JsonMediaTypeFormatter()); + + // act + IMediaTypeFormatter? result = collection.FindReader("text/plain"); + + // assert + Assert.That(result, Is.Null); + } + + /**** + ** FindWriter + ****/ + [Test(Description = "Ensure that FindWriter returns a matching formatter by media type.")] + public void FindWriter_ReturnsMatchingFormatter() + { + // arrange + MediaTypeFormatterCollection collection = []; + JsonMediaTypeFormatter jsonFormatter = new(); + XmlMediaTypeFormatter xmlFormatter = new(); + collection.Add(jsonFormatter); + collection.Add(xmlFormatter); + + // act + IMediaTypeFormatter? result = collection.FindWriter("application/xml"); + + // assert + Assert.That(result, Is.SameAs(xmlFormatter)); + } + + [Test(Description = "Ensure that FindWriter returns null when no formatter matches.")] + public void FindWriter_ReturnsNull_WhenNoMatch() + { + // arrange + MediaTypeFormatterCollection collection = []; + collection.Add(new XmlMediaTypeFormatter()); + + // act + IMediaTypeFormatter? result = collection.FindWriter("application/json"); + + // assert + Assert.That(result, Is.Null); + } +} diff --git a/Tests/Formatters/PlainTextFormatter.cs b/Tests/Formatters/PlainTextFormatter.cs index 4665d9f..5957cd9 100644 --- a/Tests/Formatters/PlainTextFormatter.cs +++ b/Tests/Formatters/PlainTextFormatter.cs @@ -72,4 +72,69 @@ public void Serialize_IFormattable_WithoutIrreversibleSerialization(Type type, o // assert Assert.Throws(() => this.GetRequest(content, formatter, type)); } + + /**** + ** CanReadType + ****/ + [Test(Description = "Ensure that CanReadType returns true for string.")] + public void CanReadType_String_ReturnsTrue() + { + PlainTextFormatter formatter = new(); + Assert.That(formatter.CanReadType(typeof(string)), Is.True); + } + + [Test(Description = "Ensure that CanReadType returns false for int.")] + public void CanReadType_Int_ReturnsFalse() + { + PlainTextFormatter formatter = new(); + Assert.That(formatter.CanReadType(typeof(int)), Is.False); + } + + [Test(Description = "Ensure that CanReadType returns false for object.")] + public void CanReadType_Object_ReturnsFalse() + { + PlainTextFormatter formatter = new(); + Assert.That(formatter.CanReadType(typeof(object)), Is.False); + } + + /**** + ** CanWriteType + ****/ + [Test(Description = "Ensure that CanWriteType returns true for string.")] + public void CanWriteType_String_ReturnsTrue() + { + PlainTextFormatter formatter = new(); + Assert.That(formatter.CanWriteType(typeof(string)), Is.True); + } + + [Test(Description = "Ensure that CanWriteType returns false for IFormattable when AllowIrreversibleSerialization is false.")] + public void CanWriteType_IFormattable_ReturnsFalse_WhenNotAllowed() + { + PlainTextFormatter formatter = new(); + Assert.That(formatter.CanWriteType(typeof(int)), Is.False); + } + + [Test(Description = "Ensure that CanWriteType returns true for IFormattable when AllowIrreversibleSerialization is true.")] + public void CanWriteType_IFormattable_ReturnsTrue_WhenAllowed() + { + PlainTextFormatter formatter = new() { AllowIrreversibleSerialization = true }; + Assert.That(formatter.CanWriteType(typeof(int)), Is.True); + } + + /**** + ** Null value + ****/ + [Test(Description = "Ensure that null value serializes to empty string.")] + public void Serialize_NullValue() + { + // arrange + PlainTextFormatter formatter = new(); + HttpRequestMessage request = this.GetRequest("placeholder", formatter); + + // act + string result = this.GetSerialized(null, request, formatter); + + // assert + Assert.That(result, Is.EqualTo(string.Empty)); + } } \ No newline at end of file diff --git a/Tests/Formatters/XmlMediaTypeFormatterTests.cs b/Tests/Formatters/XmlMediaTypeFormatterTests.cs new file mode 100644 index 0000000..e16295e --- /dev/null +++ b/Tests/Formatters/XmlMediaTypeFormatterTests.cs @@ -0,0 +1,184 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using NUnit.Framework; +using Pathoschild.Http.Client.Formatters; + +namespace Pathoschild.Http.Tests.Formatters; + +/// Unit tests verifying that the correctly serializes and deserializes content. +[TestFixture] +public class XmlMediaTypeFormatterTests +{ + /********* + ** Unit tests + *********/ + /**** + ** SupportedMediaTypes + ****/ + [Test(Description = "Ensure that the formatter supports 'application/xml'.")] + public void SupportedMediaTypes_ContainsApplicationXml() + { + XmlMediaTypeFormatter formatter = new(); + Assert.That(formatter.SupportedMediaTypes, Does.Contain("application/xml")); + } + + [Test(Description = "Ensure that the formatter supports 'text/xml'.")] + public void SupportedMediaTypes_ContainsTextXml() + { + XmlMediaTypeFormatter formatter = new(); + Assert.That(formatter.SupportedMediaTypes, Does.Contain("text/xml")); + } + + /**** + ** CanReadType / CanWriteType + ****/ + [Test(Description = "Ensure that CanReadType returns true for all types.")] + [TestCase(typeof(string))] + [TestCase(typeof(int))] + [TestCase(typeof(XmlSampleModel))] + public void CanReadType_ReturnsTrue(Type type) + { + XmlMediaTypeFormatter formatter = new(); + Assert.That(formatter.CanReadType(type), Is.True); + } + + [Test(Description = "Ensure that CanWriteType returns true for all types.")] + [TestCase(typeof(string))] + [TestCase(typeof(int))] + [TestCase(typeof(XmlSampleModel))] + public void CanWriteType_ReturnsTrue(Type type) + { + XmlMediaTypeFormatter formatter = new(); + Assert.That(formatter.CanWriteType(type), Is.True); + } + + /**** + ** Round-trip with DataContractSerializer (default) + ****/ + [Test(Description = "Ensure that a model round-trips correctly with DataContractSerializer.")] + public async Task RoundTrip_DataContractSerializer() + { + // arrange + XmlMediaTypeFormatter formatter = new(); + XmlSampleModel original = new() { Id = 42, Name = "test" }; + + // act + object? result = await this.RoundTrip(original, typeof(XmlSampleModel), formatter); + + // assert + XmlSampleModel deserialized = (XmlSampleModel)result!; + Assert.That(deserialized.Id, Is.EqualTo(42)); + Assert.That(deserialized.Name, Is.EqualTo("test")); + } + + [Test(Description = "Ensure that a string round-trips correctly with DataContractSerializer.")] + public async Task RoundTrip_DataContractSerializer_String() + { + XmlMediaTypeFormatter formatter = new(); + object? result = await this.RoundTrip("hello", typeof(string), formatter); + Assert.That(result, Is.EqualTo("hello")); + } + + [Test(Description = "Ensure that an integer round-trips correctly with DataContractSerializer.")] + public async Task RoundTrip_DataContractSerializer_Int() + { + XmlMediaTypeFormatter formatter = new(); + object? result = await this.RoundTrip(42, typeof(int), formatter); + Assert.That(result, Is.EqualTo(42)); + } + + /**** + ** Round-trip with XmlSerializer + ****/ + [Test(Description = "Ensure that a model round-trips correctly with XmlSerializer.")] + public async Task RoundTrip_XmlSerializer() + { + // arrange + XmlMediaTypeFormatter formatter = new() { UseXmlSerializer = true }; + XmlSampleModel original = new() { Id = 42, Name = "test" }; + + // act + object? result = await this.RoundTrip(original, typeof(XmlSampleModel), formatter); + + // assert + XmlSampleModel deserialized = (XmlSampleModel)result!; + Assert.That(deserialized.Id, Is.EqualTo(42)); + Assert.That(deserialized.Name, Is.EqualTo("test")); + } + + [Test(Description = "Ensure that a string round-trips correctly with XmlSerializer.")] + public async Task RoundTrip_XmlSerializer_String() + { + XmlMediaTypeFormatter formatter = new() { UseXmlSerializer = true }; + object? result = await this.RoundTrip("hello", typeof(string), formatter); + Assert.That(result, Is.EqualTo("hello")); + } + + /**** + ** Error handling + ****/ + [Test(Description = "Ensure that deserializing invalid XML throws.")] + public void ReadFromStreamAsync_InvalidXml_Throws() + { + // arrange + XmlMediaTypeFormatter formatter = new(); + byte[] bytes = Encoding.UTF8.GetBytes("not valid xml <<<"); + using MemoryStream stream = new(bytes); + using StringContent content = new(""); + + // act & assert + Assert.ThrowsAsync(async () => + await formatter.ReadFromStreamAsync(typeof(XmlSampleModel), stream, content, CancellationToken.None) + ); + } + + [Test(Description = "Ensure that deserializing invalid XML throws with XmlSerializer.")] + public void ReadFromStreamAsync_InvalidXml_XmlSerializer_Throws() + { + // arrange + XmlMediaTypeFormatter formatter = new() { UseXmlSerializer = true }; + byte[] bytes = Encoding.UTF8.GetBytes("not valid xml <<<"); + using MemoryStream stream = new(bytes); + using StringContent content = new(""); + + // act & assert + Assert.ThrowsAsync(async () => + await formatter.ReadFromStreamAsync(typeof(XmlSampleModel), stream, content, CancellationToken.None) + ); + } + + + /********* + ** Private methods + *********/ + /// Serialize then deserialize a value through the formatter. + private async Task RoundTrip(object value, Type type, XmlMediaTypeFormatter formatter) + { + using MemoryStream stream = new(); + using StringContent content = new(""); + + await formatter.WriteToStreamAsync(type, value, stream, content, CancellationToken.None); + stream.Position = 0; + return await formatter.ReadFromStreamAsync(type, stream, content, CancellationToken.None); + } + + + /********* + ** Test models + *********/ + [DataContract] + public class XmlSampleModel + { + [DataMember] + public int Id { get; set; } + + [DataMember] + public string? Name { get; set; } + } +} diff --git a/Tests/Internal/FormatterContentTests.cs b/Tests/Internal/FormatterContentTests.cs new file mode 100644 index 0000000..4d771af --- /dev/null +++ b/Tests/Internal/FormatterContentTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Pathoschild.Http.Client.Formatters; +using Pathoschild.Http.Client.Internal; + +namespace Pathoschild.Http.Tests.Internal; + +/// Unit tests verifying that correctly constructs HTTP content. +[TestFixture] +public class FormatterContentTests +{ + /********* + ** Unit tests + *********/ + [Test(Description = "Ensure that the constructor throws when the formatter can't write the type.")] + public void Constructor_ThrowsWhenFormatterCantWriteType() + { + // arrange + PlainTextFormatter formatter = new(); + + // act & assert + Assert.Throws(() => new FormatterContent(42, formatter)); + } + + [Test(Description = "Ensure that Content-Type is set from the explicit media type.")] + public void Constructor_SetsContentType_FromExplicitMediaType() + { + // arrange + JsonMediaTypeFormatter formatter = new(); + + // act + using FormatterContent content = new("test", formatter, "text/json"); + + // assert + Assert.That(content.Headers.ContentType?.MediaType, Is.EqualTo("text/json")); + } + + [Test(Description = "Ensure that Content-Type defaults to the formatter's first supported media type.")] + public void Constructor_SetsContentType_DefaultsToFirstSupported() + { + // arrange + JsonMediaTypeFormatter formatter = new(); + + // act + using FormatterContent content = new("test", formatter); + + // assert + Assert.That(content.Headers.ContentType?.MediaType, Is.EqualTo("application/json")); + } + + [Test(Description = "Ensure that Content-Type falls back to application/octet-stream when formatter has no supported types.")] + public void Constructor_SetsContentType_FallsBackToOctetStream() + { + // arrange + StubFormatter formatter = new(); + + // act + using FormatterContent content = new("test", formatter); + + // assert + Assert.That(content.Headers.ContentType?.MediaType, Is.EqualTo("application/octet-stream")); + } + + [Test(Description = "Ensure that the content serializes to the stream correctly.")] + public async Task SerializeToStreamAsync_WritesContent() + { + // arrange + JsonMediaTypeFormatter formatter = new(); + using FormatterContent content = new("hello", formatter); + + // act + using MemoryStream stream = new(); + await content.CopyToAsync(stream); + stream.Position = 0; + string result = new StreamReader(stream).ReadToEnd(); + + // assert + Assert.That(result, Is.EqualTo("\"hello\"")); + } + + + /********* + ** Test helpers + *********/ + /// A stub formatter with no supported media types. + private class StubFormatter : IMediaTypeFormatter + { + public IReadOnlyCollection SupportedMediaTypes { get; } = Array.Empty(); + public bool CanReadType(Type type) { return true; } + public bool CanWriteType(Type type) { return true; } + + public Task ReadFromStreamAsync(Type type, Stream stream, HttpContent content, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task WriteToStreamAsync(Type type, object? value, Stream stream, HttpContent content, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/Tests/Internal/HttpContentExtensionsTests.cs b/Tests/Internal/HttpContentExtensionsTests.cs new file mode 100644 index 0000000..dabfd34 --- /dev/null +++ b/Tests/Internal/HttpContentExtensionsTests.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Pathoschild.Http.Client.Formatters; +using Pathoschild.Http.Client.Internal; + +namespace Pathoschild.Http.Tests.Internal; + +/// Unit tests verifying that correctly reads content using formatters. +[TestFixture] +public class HttpContentExtensionsTests +{ + /********* + ** Unit tests + *********/ + [Test(Description = "Ensure that ReadAsAsync matches a formatter by Content-Type header.")] + public async Task ReadAsAsync_MatchesByContentType() + { + // arrange + string json = "{\"Id\":1,\"Name\":\"test\"}"; + using HttpContent content = new StringContent(json, Encoding.UTF8, "application/json"); + MediaTypeFormatterCollection formatters = [new JsonMediaTypeFormatter(), new XmlMediaTypeFormatter()]; + + // act + SampleModel result = await content.ReadAsAsync(formatters); + + // assert + Assert.That(result.Id, Is.EqualTo(1)); + Assert.That(result.Name, Is.EqualTo("test")); + } + + [Test(Description = "Ensure that ReadAsAsync falls back to first formatter that can read the type when no Content-Type match.")] + public async Task ReadAsAsync_FallsBackToFirstReadableFormatter() + { + // arrange + string json = "{\"Id\":2,\"Name\":\"fallback\"}"; + using ByteArrayContent content = new(Encoding.UTF8.GetBytes(json)); + content.Headers.ContentType = new MediaTypeHeaderValue("application/custom-type"); + MediaTypeFormatterCollection formatters = [new JsonMediaTypeFormatter()]; + + // act + SampleModel result = await content.ReadAsAsync(formatters); + + // assert + Assert.That(result.Id, Is.EqualTo(2)); + Assert.That(result.Name, Is.EqualTo("fallback")); + } + + [Test(Description = "Ensure that ReadAsAsync throws when no formatter can read the type.")] + public void ReadAsAsync_ThrowsWhenNoFormatterCanRead() + { + // arrange + using HttpContent content = new StringContent("hello", Encoding.UTF8, "text/plain"); + PlainTextFormatter plainText = new(); + MediaTypeFormatterCollection formatters = [plainText]; + + // act & assert (PlainTextFormatter can only read strings, not SampleModel) + Assert.ThrowsAsync(async () => + await content.ReadAsAsync(formatters) + ); + } + + [Test(Description = "Ensure that ReadAsAsync handles null Content-Type gracefully.")] + public async Task ReadAsAsync_HandlesNullContentType() + { + // arrange + string json = "{\"Id\":3,\"Name\":\"noheader\"}"; + using ByteArrayContent content = new(Encoding.UTF8.GetBytes(json)); + content.Headers.ContentType = null; + MediaTypeFormatterCollection formatters = [new JsonMediaTypeFormatter()]; + + // act + SampleModel result = await content.ReadAsAsync(formatters); + + // assert + Assert.That(result.Id, Is.EqualTo(3)); + Assert.That(result.Name, Is.EqualTo("noheader")); + } + + + /********* + ** Test models + *********/ + public class SampleModel + { + public int Id { get; set; } + public string? Name { get; set; } + } +}