From a2ea3c7fbeaf3b9e0933f5e86f9562dd70ac9d6d Mon Sep 17 00:00:00 2001 From: Kambiz Date: Wed, 7 Oct 2026 21:39:07 +0800 Subject: [PATCH 1/6] Read problem details, text/xml and suffixed media types The JSON formatters read only application/json and the XML formatter only application/xml. RFC 9457 problem details (application/problem+json and application/problem+xml) returned in error responses could not be read into ResponseErrorType, so HttpResponseException.ResponseObject was null. text/xml, which RFC 7303 registers with the same definition as application/xml, and vendor media types with a +json or +xml structured syntax suffix could not be read either. JsonFormatter and NewtonsoftJsonFormatter now read application/problem+json, and XmlFormatter reads text/xml and application/problem+xml; these are also advertised in the Accept header. Writing is unchanged. HttpContentFormatter gains CanReadMediaType, which CanRead calls to decide whether a media type is readable, and HasStructuredSyntaxSuffix. The JSON formatters override CanReadMediaType to read any +json media type and XmlFormatter any +xml media type. These are read but not advertised. The client still reads a response with the first formatter that can read it, so a formatter for a specific suffixed media type must be added before the JSON or XML formatter. Add MediaTypeNames.Application.ProblemJson and ProblemXml. --- docs/content-formats.md | 8 ++-- docs/error-handling.md | 2 +- src/Kampute.HttpClient.Json/JsonFormatter.cs | 27 +++++++++++- .../NewtonsoftJsonFormatter.cs | 27 +++++++++++- .../Content/Abstracts/HttpContentFormatter.cs | 43 +++++++++++++++++-- src/Kampute.HttpClient/MediaTypeNames.cs | 10 +++++ src/Kampute.HttpClient/Xml/XmlFormatter.cs | 25 ++++++++++- .../HttpRestClientJsonExtensionsTests.cs | 27 ++++++++++++ .../JsonFormatterTests.cs | 19 +++++++- .../NewtonsoftJsonFormatterTests.cs | 19 +++++++- .../Abstracts/HttpContentFormatterTests.cs | 37 ++++++++++++++++ .../Xml/HttpRestClientXmlExtensionsTests.cs | 2 +- .../Xml/XmlFormatterTests.cs | 10 ++++- 13 files changed, 239 insertions(+), 17 deletions(-) diff --git a/docs/content-formats.md b/docs/content-formats.md index 54ca02f..51e95b4 100644 --- a/docs/content-formats.md +++ b/docs/content-formats.md @@ -13,7 +13,7 @@ The base package registers no content formatter. Each format registers its forma ## JSON -Install either JSON package as described in [Getting started](getting-started.md). Pass serializer options when registering the formatter: +Both JSON formatters read `application/json`, `application/problem+json`, and any other media type with the `+json` suffix, such as `application/vnd.example+json`, and write `application/json`. Install either JSON package as described in [Getting started](getting-started.md). Pass serializer options when registering the formatter: ```csharp using System.Text.Json; @@ -28,7 +28,7 @@ For [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_J ## XML -[`UseXml()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_UseXml_Kampute_HttpClient_HttpRestClient_System_Action{Kampute_HttpClient_Xml_XmlFormatter}_) registers an [`XmlFormatter`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html). Its [`Serializer`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html#Kampute_HttpClient_Xml_XmlFormatter_Serializer) setting chooses the serializer. With the default, [`XmlSerializerKind.Auto`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields), types marked with [`[DataContract]`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractattribute) or [`[CollectionDataContract]`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.collectiondatacontractattribute) use [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer), and all other types use [`XmlSerializer`](https://learn.microsoft.com/dotnet/api/system.xml.serialization.xmlserializer). The rule applies to responses by the requested type and to payloads by their runtime type. Set [`XmlSerializerKind.XmlSerializer`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields) or [`XmlSerializerKind.DataContractSerializer`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields) to use one serializer for every type, and [`DataContractSettings`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html#Kampute_HttpClient_Xml_XmlFormatter_DataContractSettings) to configure [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer). +[`UseXml()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_UseXml_Kampute_HttpClient_HttpRestClient_System_Action{Kampute_HttpClient_Xml_XmlFormatter}_) registers an [`XmlFormatter`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html), which reads `application/xml`, `text/xml`, `application/problem+xml`, and any other media type with the `+xml` suffix, and writes `application/xml`. Its [`Serializer`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html#Kampute_HttpClient_Xml_XmlFormatter_Serializer) setting chooses the serializer. With the default, [`XmlSerializerKind.Auto`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields), types marked with [`[DataContract]`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractattribute) or [`[CollectionDataContract]`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.collectiondatacontractattribute) use [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer), and all other types use [`XmlSerializer`](https://learn.microsoft.com/dotnet/api/system.xml.serialization.xmlserializer). The rule applies to responses by the requested type and to payloads by their runtime type. Set [`XmlSerializerKind.XmlSerializer`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields) or [`XmlSerializerKind.DataContractSerializer`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields) to use one serializer for every type, and [`DataContractSettings`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html#Kampute_HttpClient_Xml_XmlFormatter_DataContractSettings) to configure [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer). The following POST writes to your API; `Resource` is your application's serializable model. @@ -45,7 +45,9 @@ await client.PostAsXmlAsync("https://api.example.com/resources", new Resource { ## Custom Formatters -You can also implement a content formatter for an application-specific content type. Derive from [`HttpContentFormatter`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html) and pass the media types it reads and the media types it writes to the base constructor. Override [`ReadContentAsync`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_ReadContentAsync_System_Net_Http_HttpContent_System_Type_System_Threading_CancellationToken_) to read responses, [`CreateContent`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_CreateContent_System_Object_System_String_) to write request payloads, or both. A formatter that only reads passes an empty list of writable media types, and one that only writes passes an empty list of readable media types. +A response with a `+json` or `+xml` media type, such as `application/vnd.example.resource+json`, does not need its own formatter, because the JSON and XML formatters read it. Implement a custom formatter when you need to write such a media type, advertise it in the `Accept` header, or read it differently. The client reads a response with the first registered formatter that can read it, so add a custom formatter for a `+json` or `+xml` media type before the JSON or XML formatter. + +You can also implement a content formatter for an application-specific content type. Derive from [`HttpContentFormatter`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html) and pass the media types it reads and the media types it writes to the base constructor. Override [`ReadContentAsync`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_ReadContentAsync_System_Net_Http_HttpContent_System_Type_System_Threading_CancellationToken_) to read responses, [`CreateContent`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_CreateContent_System_Object_System_String_) to write request payloads, or both. A formatter that only reads passes an empty list of writable media types, and one that only writes passes an empty list of readable media types. To read media types beyond the listed ones, override [`CanReadMediaType`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_CanReadMediaType_System_String_); [`HasStructuredSyntaxSuffix`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_HasStructuredSyntaxSuffix_System_String_System_String_) tells whether a media type ends with a suffix such as `+json`. Media types accepted this way are read but not advertised in the `Accept` header. This skeleton shows the overrides to implement; replace both [`NotImplementedException`](https://learn.microsoft.com/dotnet/api/system.notimplementedexception) statements with your format's read and write logic before registering it. diff --git a/docs/error-handling.md b/docs/error-handling.md index f46dbe5..c304031 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -27,7 +27,7 @@ The retry strategy comes from the [`Kampute.Resilience`](https://kampute.github. ## Structured Error Bodies -Set [`ResponseErrorType`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ResponseErrorType) to your API's error model. Register a [content formatter](content-formats.md) that can read that model from the response media type. +Set [`ResponseErrorType`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ResponseErrorType) to your API's error model. Register a [content formatter](content-formats.md) that can read that model from the response media type. The JSON and XML formatters also read RFC 9457 problem details, `application/problem+json` and `application/problem+xml`, so an error model with the problem details fields works with them. The deserialized model is available through [`HttpResponseException.ResponseObject`](~/api/Kampute.HttpClient.HttpResponseException.html#Kampute_HttpClient_HttpResponseException_ResponseObject). If the model implements [`IHttpErrorResponse`](~/api/Kampute.HttpClient.Interfaces.IHttpErrorResponse.html), its [`ToException()`](~/api/Kampute.HttpClient.Interfaces.IHttpErrorResponse.html#Kampute_HttpClient_Interfaces_IHttpErrorResponse_ToException_System_Net_HttpStatusCode_) method constructs the exception. See the reference for the full error-response contract. diff --git a/src/Kampute.HttpClient.Json/JsonFormatter.cs b/src/Kampute.HttpClient.Json/JsonFormatter.cs index 9d73ecc..cdfe631 100644 --- a/src/Kampute.HttpClient.Json/JsonFormatter.cs +++ b/src/Kampute.HttpClient.Json/JsonFormatter.cs @@ -17,9 +17,21 @@ namespace Kampute.HttpClient.Json /// Reads and writes application/json content with System.Text.Json. /// /// + /// + /// The formatter also reads application/problem+json, the problem details format of RFC 9457 for error responses, and advertises it in + /// the Accept header. It reads any other media type with the +json structured syntax suffix, which RFC 6839 permits for media + /// types whose representation follows application/json, such as application/vnd.example+json, but does not advertise it. It writes + /// application/json only. + /// + /// + /// The client reads a response with the first formatter in that can read it, so a formatter for a + /// specific +json media type takes over that type only when it is added before this one. + /// + /// /// Register the formatter with . Its apply both to the responses it /// reads and to the payloads it writes. Change them before the client sends requests, because a registered formatter is shared by all requests /// of the client. + /// /// public sealed class JsonFormatter : HttpContentFormatter { @@ -27,7 +39,7 @@ public sealed class JsonFormatter : HttpContentFormatter /// Initializes a new instance of the class. /// public JsonFormatter() - : base([MediaTypeNames.Application.Json], [MediaTypeNames.Application.Json]) + : base([MediaTypeNames.Application.Json, MediaTypeNames.Application.ProblemJson], [MediaTypeNames.Application.Json]) { } @@ -39,6 +51,19 @@ public JsonFormatter() /// public JsonSerializerOptions? Options { get; set; } + /// + /// Determines whether this formatter can read content of the specified media type. + /// + /// The media type of the content, without parameters. + /// + /// if is one of the or has the + /// +json structured syntax suffix; otherwise, . + /// + protected override bool CanReadMediaType(string mediaType) + { + return base.CanReadMediaType(mediaType) || HasStructuredSyntaxSuffix(mediaType, "+json"); + } + /// /// Asynchronously reads an object of the specified type from JSON content. /// diff --git a/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs index 53d1cd9..591858e 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs @@ -18,9 +18,21 @@ namespace Kampute.HttpClient.NewtonsoftJson /// Reads and writes application/json content with Newtonsoft.Json. /// /// + /// + /// The formatter also reads application/problem+json, the problem details format of RFC 9457 for error responses, and advertises it in + /// the Accept header. It reads any other media type with the +json structured syntax suffix, which RFC 6839 permits for media + /// types whose representation follows application/json, such as application/vnd.example+json, but does not advertise it. It writes + /// application/json only. + /// + /// + /// The client reads a response with the first formatter in that can read it, so a formatter for a + /// specific +json media type takes over that type only when it is added before this one. + /// + /// /// Register the formatter with . Its apply both to the responses /// it reads and to the payloads it writes. Change them before the client sends requests, because a registered formatter is shared by all requests /// of the client. + /// /// public sealed class NewtonsoftJsonFormatter : HttpContentFormatter { @@ -28,7 +40,7 @@ public sealed class NewtonsoftJsonFormatter : HttpContentFormatter /// Initializes a new instance of the class. /// public NewtonsoftJsonFormatter() - : base([MediaTypeNames.Application.Json], [MediaTypeNames.Application.Json]) + : base([MediaTypeNames.Application.Json, MediaTypeNames.Application.ProblemJson], [MediaTypeNames.Application.Json]) { } @@ -40,6 +52,19 @@ public NewtonsoftJsonFormatter() /// public JsonSerializerSettings? Settings { get; set; } + /// + /// Determines whether this formatter can read content of the specified media type. + /// + /// The media type of the content, without parameters. + /// + /// if is one of the or has the + /// +json structured syntax suffix; otherwise, . + /// + protected override bool CanReadMediaType(string mediaType) + { + return base.CanReadMediaType(mediaType) || HasStructuredSyntaxSuffix(mediaType, "+json"); + } + /// /// Asynchronously reads an object of the specified type from JSON content. /// diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs index f20d6d6..26a18a4 100644 --- a/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs +++ b/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs @@ -20,7 +20,8 @@ namespace Kampute.HttpClient.Content.Abstracts /// /// A derived class passes the media types it reads and the media types it writes to the constructor. A receive-only formatter passes no writable /// media types and overrides ; a send-only formatter passes no readable media types and overrides ; - /// a two-way formatter does both. To limit the types a formatter handles, override or . + /// a two-way formatter does both. To limit the types a formatter handles, override or . To read + /// media types that are not listed, such as every media type with a structured syntax suffix, override . /// /// /// Media types are compared ignoring case. and validate their arguments before they call the @@ -80,13 +81,13 @@ public virtual IEnumerable GetReadableMediaTypes(Type modelType) /// The media type of the content. /// The type of the object to read. /// - /// if is one of the and accepts + /// if accepts and accepts /// ; otherwise, . /// public virtual bool CanRead(string mediaType, Type modelType) { return mediaType is not null && modelType is not null - && ReadableMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) + && CanReadMediaType(mediaType) && CanReadType(modelType); } @@ -163,6 +164,42 @@ public HttpContent Write(object payload, string mediaType) /// if this formatter can read objects of ; otherwise, . The default is . protected virtual bool CanReadType(Type modelType) => true; + /// + /// Determines whether this formatter can read content of the specified media type. + /// + /// The media type of the content, without parameters. + /// + /// if is one of the , ignoring case; otherwise, . + /// + /// + /// calls this method with a non-null media type. A media type accepted only by an override is read but not advertised + /// in the Accept header, which lists . + /// + protected virtual bool CanReadMediaType(string mediaType) => ReadableMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase); + + /// + /// Determines whether a media type ends with the specified structured syntax suffix. + /// + /// The media type to check, such as application/vnd.example+json. + /// The suffix, including its plus sign, such as +json. + /// + /// if has a subtype name before and ends with it, ignoring case; + /// otherwise, . + /// + /// Thrown if or is . + protected static bool HasStructuredSyntaxSuffix(string mediaType, string suffix) + { + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + if (suffix is null) + throw new ArgumentNullException(nameof(suffix)); + + var slash = mediaType.IndexOf('/'); + return slash > 0 + && mediaType.Length - suffix.Length > slash + 1 + && mediaType.EndsWith(suffix, StringComparison.OrdinalIgnoreCase); + } + /// /// Determines whether this formatter can write payloads of the specified type. /// diff --git a/src/Kampute.HttpClient/MediaTypeNames.cs b/src/Kampute.HttpClient/MediaTypeNames.cs index 08327da..da19bb8 100644 --- a/src/Kampute.HttpClient/MediaTypeNames.cs +++ b/src/Kampute.HttpClient/MediaTypeNames.cs @@ -61,6 +61,16 @@ public static class Application public const string Json = "application/json"; #endif + /// + /// Media type name for problem details in JSON, defined by RFC 9457. + /// + public const string ProblemJson = "application/problem+json"; + + /// + /// Media type name for problem details in XML, defined by RFC 9457. + /// + public const string ProblemXml = "application/problem+xml"; + /// /// Media type name for BSON data. /// diff --git a/src/Kampute.HttpClient/Xml/XmlFormatter.cs b/src/Kampute.HttpClient/Xml/XmlFormatter.cs index 34c8171..3bc81b6 100644 --- a/src/Kampute.HttpClient/Xml/XmlFormatter.cs +++ b/src/Kampute.HttpClient/Xml/XmlFormatter.cs @@ -18,6 +18,16 @@ namespace Kampute.HttpClient.Xml /// /// /// + /// The formatter also reads text/xml, which RFC 7303 registers with the same definition as application/xml, and + /// application/problem+xml, the problem details format of RFC 9457 for error responses, and advertises both in the Accept header. + /// It reads any other media type with the +xml structured syntax suffix, which RFC 7303 registers for XML media types, such as + /// application/vnd.example+xml, but does not advertise it. It writes application/xml only. + /// + /// + /// The client reads a response with the first formatter in that can read it, so a formatter for a + /// specific +xml media type takes over that type only when it is added before this one. + /// + /// /// The property selects the serializer. The same rule applies when a response is read, by the requested model type, and when /// a payload is written, by the runtime type of the payload. With the default, , a type marked with /// or uses , and any other type @@ -34,7 +44,7 @@ public sealed class XmlFormatter : HttpContentFormatter /// Initializes a new instance of the class. /// public XmlFormatter() - : base([MediaTypeNames.Application.Xml], [MediaTypeNames.Application.Xml]) + : base([MediaTypeNames.Application.Xml, MediaTypeNames.Text.Xml, MediaTypeNames.Application.ProblemXml], [MediaTypeNames.Application.Xml]) { } @@ -55,6 +65,19 @@ public XmlFormatter() /// public DataContractSerializerSettings? DataContractSettings { get; set; } + /// + /// Determines whether this formatter can read content of the specified media type. + /// + /// The media type of the content, without parameters. + /// + /// if is one of the or has the + /// +xml structured syntax suffix; otherwise, . + /// + protected override bool CanReadMediaType(string mediaType) + { + return base.CanReadMediaType(mediaType) || HasStructuredSyntaxSuffix(mediaType, "+xml"); + } + /// /// Asynchronously reads an object of the specified type from XML content. /// diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index 4b11534..4beb4c6 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -291,6 +291,33 @@ public void UseJson_RegistersOneFormatterAndUpdatesItsOptions() } } + [Test] + public void ErrorResponse_WithProblemJson_IsReadIntoResponseErrorType() + { + _restClient.ResponseErrorType = typeof(TestModel); + _mockMessageHandler.MockHttpResponse(request => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(new TestModel { Name = "Invalid" }.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.ProblemJson), + }); + + var exception = Assert.ThrowsAsync(() => _restClient.GetAsync("/resource")); + + Assert.That(exception.ResponseObject, Is.EqualTo(new TestModel { Name = "Invalid" })); + } + + [Test] + public async Task Response_WithJsonSuffixMediaType_IsRead() + { + _mockMessageHandler.MockHttpResponse(request => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(new TestModel { Name = "Vendor" }.ToJsonString(), Encoding.UTF8, "application/vnd.example.resource+json"), + }); + + var result = await _restClient.GetAsync("/resource"); + + Assert.That(result, Is.EqualTo(new TestModel { Name = "Vendor" })); + } + [Test] public async Task UseJson_OptionsApplyToRequestAndResponse() { diff --git a/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs b/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs index 5c9d274..9119608 100644 --- a/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs @@ -9,14 +9,29 @@ public class JsonFormatterTests { [Test] - public void MediaTypes_AreApplicationJsonInBothDirections() + public void MediaTypes_ReadJsonAndProblemJson_WriteJson() { var formatter = new JsonFormatter(); using (Assert.EnterMultipleScope()) { - Assert.That(formatter.GetReadableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + Assert.That(formatter.GetReadableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json, MediaTypeNames.Application.ProblemJson })); Assert.That(formatter.GetWritableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + Assert.That(formatter.CanRead(MediaTypeNames.Application.ProblemJson, typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.ProblemJson, typeof(TestModel)), Is.False); + } + } + + [Test] + public void CanRead_ForJsonSuffix_ReturnsTrueButCanWriteReturnsFalse() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead("application/vnd.example+json", typeof(TestModel)), Is.True); + Assert.That(formatter.CanRead("application/vnd.example+xml", typeof(TestModel)), Is.False); + Assert.That(formatter.CanWrite("application/vnd.example+json", typeof(TestModel)), Is.False); } } diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs index b398a29..3d095d9 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs @@ -9,14 +9,29 @@ public class NewtonsoftJsonFormatterTests { [Test] - public void MediaTypes_AreApplicationJsonInBothDirections() + public void MediaTypes_ReadJsonAndProblemJson_WriteJson() { var formatter = new NewtonsoftJsonFormatter(); using (Assert.EnterMultipleScope()) { - Assert.That(formatter.GetReadableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + Assert.That(formatter.GetReadableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json, MediaTypeNames.Application.ProblemJson })); Assert.That(formatter.GetWritableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + Assert.That(formatter.CanRead(MediaTypeNames.Application.ProblemJson, typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.ProblemJson, typeof(TestModel)), Is.False); + } + } + + [Test] + public void CanRead_ForJsonSuffix_ReturnsTrueButCanWriteReturnsFalse() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead("application/vnd.example+json", typeof(TestModel)), Is.True); + Assert.That(formatter.CanRead("application/vnd.example+xml", typeof(TestModel)), Is.False); + Assert.That(formatter.CanWrite("application/vnd.example+json", typeof(TestModel)), Is.False); } } diff --git a/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs b/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs index 5750f16..7e43d84 100644 --- a/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs +++ b/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs @@ -29,6 +29,31 @@ public void MediaTypeQueries_HonorTheTypeFiltersAndIgnoreCase() } } + [TestCase("application/vnd.example+json", ExpectedResult = true)] + [TestCase("Application/Vnd.Example+JSON", ExpectedResult = true)] + [TestCase("application/json", ExpectedResult = false)] + [TestCase("application/+json", ExpectedResult = false)] + [TestCase("application/vnd.example+json-seq", ExpectedResult = false)] + [TestCase("vnd.example+json", ExpectedResult = false)] + public bool HasStructuredSyntaxSuffix_RequiresSubtypeNameBeforeSuffix(string mediaType) + { + return SuffixFormatter.HasSuffix(mediaType, "+json"); + } + + [Test] + public void CanRead_UsesCanReadMediaTypeButAdvertisesOnlyListedMediaTypes() + { + var formatter = new SuffixFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead("text/x-read", typeof(string)), Is.True); + Assert.That(formatter.CanRead("application/vnd.example+test", typeof(string)), Is.True); + Assert.That(formatter.CanRead("application/vnd.example+other", typeof(string)), Is.False); + Assert.That(formatter.GetReadableMediaTypes(typeof(string)), Is.EqualTo(new[] { "text/x-read" })); + } + } + [Test] public void Write_WithUnsupportedMediaTypeOrPayload_ThrowsNotSupportedException() { @@ -112,6 +137,18 @@ protected override HttpContent CreateContent(object payload, string mediaType) } } + private sealed class SuffixFormatter : HttpContentFormatter + { + public SuffixFormatter() + : base(["text/x-read"], []) + { + } + + public static bool HasSuffix(string mediaType, string suffix) => HasStructuredSyntaxSuffix(mediaType, suffix); + + protected override bool CanReadMediaType(string mediaType) => base.CanReadMediaType(mediaType) || HasStructuredSyntaxSuffix(mediaType, "+test"); + } + private sealed class NoOverridesFormatter : HttpContentFormatter { public NoOverridesFormatter() diff --git a/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs index e3a0dda..8223f77 100644 --- a/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs @@ -77,7 +77,7 @@ public async Task UseXml_AdvertisesXmlInAcceptHeader() using (Assert.EnterMultipleScope()) { - Assert.That(accepted, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(accepted, Is.EqualTo($"{MediaTypeNames.Application.Xml}, {MediaTypeNames.Text.Xml}, {MediaTypeNames.Application.ProblemXml}")); Assert.That(result, Is.EqualTo(new PlainModel { Name = "Test" })); } } diff --git a/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs b/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs index 116e323..4afe0dc 100644 --- a/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs +++ b/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs @@ -19,16 +19,22 @@ public void Serializer_DefaultsToAuto() } [Test] - public void MediaTypes_AreApplicationXmlInBothDirections() + public void MediaTypes_ReadXmlTextXmlAndProblemXml_WriteXml() { var formatter = new XmlFormatter(); using (Assert.EnterMultipleScope()) { - Assert.That(formatter.GetReadableMediaTypes(typeof(PlainModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Xml })); + Assert.That(formatter.GetReadableMediaTypes(typeof(PlainModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Xml, MediaTypeNames.Text.Xml, MediaTypeNames.Application.ProblemXml })); Assert.That(formatter.GetWritableMediaTypes(typeof(PlainModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Xml })); Assert.That(formatter.CanRead(MediaTypeNames.Application.Xml, typeof(ContractModel)), Is.True); Assert.That(formatter.CanRead("Application/XML", typeof(PlainModel)), Is.True); + Assert.That(formatter.CanRead(MediaTypeNames.Text.Xml, typeof(PlainModel)), Is.True); + Assert.That(formatter.CanRead(MediaTypeNames.Application.ProblemXml, typeof(PlainModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Text.Xml, typeof(PlainModel)), Is.False); + Assert.That(formatter.CanRead("application/vnd.example+xml", typeof(PlainModel)), Is.True); + Assert.That(formatter.CanRead("application/vnd.example+json", typeof(PlainModel)), Is.False); + Assert.That(formatter.CanWrite("application/vnd.example+xml", typeof(PlainModel)), Is.False); Assert.That(formatter.CanRead(MediaTypeNames.Application.Json, typeof(PlainModel)), Is.False); Assert.That(formatter.CanWrite("Application/XML", typeof(ContractModel)), Is.True); Assert.That(formatter.CanWrite(MediaTypeNames.Application.Json, typeof(PlainModel)), Is.False); From cd201df8bfae644530a6dc438d3a5354ba9aa720 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Wed, 7 Oct 2026 21:39:08 +0800 Subject: [PATCH 2/6] Bump version to 3.1.0 for all project files --- src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj | 2 +- .../Kampute.HttpClient.NewtonsoftJson.csproj | 2 +- src/Kampute.HttpClient/Kampute.HttpClient.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj index 5c2693a..3ee47ac 100644 --- a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj +++ b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.Json This package is an extension package for Kampute.HttpClient, enhancing it to manage application/json content types, using System.Text.Json library for serialization and deserialization of JSON responses and payloads. Kambiz Khojasteh - 3.0.0 + 3.1.0 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj index 1f26196..1ca20b0 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj +++ b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.NewtonsoftJson This package is an extension package for Kampute.HttpClient, enhancing it to manage application/json content types, using Newtonsoft.Json library for serialization and deserialization of JSON responses and payloads. Kambiz Khojasteh - 3.0.0 + 3.1.0 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index 7ab089b..b65a105 100644 --- a/src/Kampute.HttpClient/Kampute.HttpClient.csproj +++ b/src/Kampute.HttpClient/Kampute.HttpClient.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient Kampute.HttpClient is a versatile and lightweight .NET library that simplifies RESTful API communication. Its core HttpRestClient class provides a streamlined approach to HTTP interactions, offering advanced features such as two-way content formatters with built-in XML support, robust error handling, configurable retry policies, and detailed request-response processing. Striking a balance between simplicity and extensibility, Kampute.HttpClient empowers developers with a powerful yet easy-to-use client for seamless API integration across a wide range of .NET applications. Kambiz Khojasteh - 3.0.0 + 3.1.0 Kampute Copyright (c) 2025 Kampute latest From 58a93cb8d99ab55ed064a323f272dc410208454d Mon Sep 17 00:00:00 2001 From: Kambiz Date: Wed, 7 Oct 2026 22:02:15 +0800 Subject: [PATCH 3/6] Update test package versions --- .../Kampute.HttpClient.Json.Test.csproj | 12 ++++++------ .../Kampute.HttpClient.NewtonsoftJson.Test.csproj | 12 ++++++------ .../Kampute.HttpClient.Test.csproj | 12 ++++++------ .../Kampute.HttpClient.TestSupport.csproj | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj b/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj index a505ff2..f46bc76 100644 --- a/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj +++ b/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj @@ -10,15 +10,15 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj index 2fac162..bf29679 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj @@ -10,15 +10,15 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj b/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj index d818fdf..b9a1a83 100644 --- a/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj +++ b/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj @@ -10,15 +10,15 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Kampute.HttpClient.TestSupport/Kampute.HttpClient.TestSupport.csproj b/tests/Kampute.HttpClient.TestSupport/Kampute.HttpClient.TestSupport.csproj index 7a11c6f..96670d4 100644 --- a/tests/Kampute.HttpClient.TestSupport/Kampute.HttpClient.TestSupport.csproj +++ b/tests/Kampute.HttpClient.TestSupport/Kampute.HttpClient.TestSupport.csproj @@ -10,7 +10,7 @@ - + From 00eb667c86d0a072710faea52bd35e9c92fd8797 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Wed, 7 Oct 2026 22:03:22 +0800 Subject: [PATCH 4/6] Remove unused System.Net.Http using Delete the unused "using System.Net.Http;" from Kampute.HttpClient.HttpRequestMessagePropertyKeys.cs to clean up imports and avoid an unnecessary dependency on System.Net.Http (no types from that namespace are referenced). --- src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs index 7a1f8f0..325dd9d 100644 --- a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs @@ -7,7 +7,6 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; using System; - using System.Net.Http; /// /// Provides the keys of the request properties that sets and reads. From 3df005bceea1f97a58c1426f2f1e6027134d5b5b Mon Sep 17 00:00:00 2001 From: Kambiz Date: Wed, 7 Oct 2026 22:14:15 +0800 Subject: [PATCH 5/6] Await async assertions for NUnit 5 NUnit 5.0.0 changed Assert.ThrowsAsync, Assert.CatchAsync and Assert.DoesNotThrowAsync to return a Task that must be awaited for the assertion to be evaluated. The test projects no longer compiled: NUnit.Analyzers reported each unawaited call as NUnit2059, and code that read the returned exception failed with CS1061. Await these assertions and make the enclosing tests async. The .NET Framework test project still uses NUnit 4.6.1 and is unchanged. --- .../HttpRestClientJsonExtensionsTests.cs | 8 +++--- .../HttpRestClientJsonExtensionsTests.cs | 4 +-- .../Compression/GzipCompressedContentTests.cs | 4 +-- .../DynamicHttpErrorHandlerTests.cs | 8 +++--- .../ErrorHandlers/HttpError401HandlerTests.cs | 4 +-- .../ErrorHandlers/HttpError429HandlerTests.cs | 4 +-- .../ErrorHandlers/HttpError503HandlerTests.cs | 4 +-- .../RetryableHttpErrorHandlerTests.cs | 10 +++---- .../HttpRestClientFormExtensionsTests.cs | 16 ++++++------ .../HttpRestClientTests.cs | 26 +++++++++---------- .../StreamingResponseTests.cs | 8 +++--- .../Xml/HttpRestClientXmlExtensionsTests.cs | 4 +-- .../Xml/XmlFormatterTests.cs | 2 +- 13 files changed, 51 insertions(+), 51 deletions(-) diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index 4beb4c6..1ad4929 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -184,7 +184,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie [TestCase("gzip")] [TestCase("deflate")] - public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetry(string encoding) + public async Task SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetry(string encoding) { var payload = new TestModel { Name = "JSON Test" }; var attempts = 0; @@ -213,7 +213,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr }; using var compressedContent = CompressContent(content, encoding); - Assert.ThrowsAsync + await Assert.ThrowsAsync ( Is.InstanceOf(), async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) @@ -292,7 +292,7 @@ public void UseJson_RegistersOneFormatterAndUpdatesItsOptions() } [Test] - public void ErrorResponse_WithProblemJson_IsReadIntoResponseErrorType() + public async Task ErrorResponse_WithProblemJson_IsReadIntoResponseErrorType() { _restClient.ResponseErrorType = typeof(TestModel); _mockMessageHandler.MockHttpResponse(request => new HttpResponseMessage(HttpStatusCode.BadRequest) @@ -300,7 +300,7 @@ public void ErrorResponse_WithProblemJson_IsReadIntoResponseErrorType() Content = new StringContent(new TestModel { Name = "Invalid" }.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.ProblemJson), }); - var exception = Assert.ThrowsAsync(() => _restClient.GetAsync("/resource")); + var exception = await Assert.ThrowsAsync(() => _restClient.GetAsync("/resource")); Assert.That(exception.ResponseObject, Is.EqualTo(new TestModel { Name = "Invalid" })); } diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index d753690..41dfce4 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -171,7 +171,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie [TestCase("gzip")] [TestCase("deflate")] - public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetry(string encoding) + public async Task SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetry(string encoding) { var payload = new TestModel { Name = "JSON Test" }; var attempts = 0; @@ -200,7 +200,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr }; using var compressedContent = CompressContent(content, encoding); - Assert.ThrowsAsync + await Assert.ThrowsAsync ( Is.InstanceOf(), async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) diff --git a/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs b/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs index e33a39a..0173cac 100644 --- a/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs +++ b/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs @@ -59,13 +59,13 @@ public async Task Constructor_WithOriginalLengthAndHash_DoesNotCopyThem() } [Test] - public void Dispose_DisposesOriginalContent() + public async Task Dispose_DisposesOriginalContent() { using var originalContent = new StringContent("Original content"); new GzipCompressedContent(originalContent, CompressionLevel.Optimal).Dispose(); - Assert.ThrowsAsync(() => originalContent.ReadAsStringAsync()); + await Assert.ThrowsAsync(() => originalContent.ReadAsStringAsync()); } } } diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs index 4314079..2036da8 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs @@ -36,7 +36,7 @@ public void Cleanup() } [Test] - public void OnErrorResponse_InvokesDelegateWithResponseContext() + public async Task OnErrorResponse_InvokesDelegateWithResponseContext() { var seenStatusCodes = new List(); _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => @@ -47,7 +47,7 @@ public void OnErrorResponse_InvokesDelegateWithResponseContext() _mockMessageHandler.MockHttpResponse(HttpStatusCode.Conflict); - var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + var exception = await Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); using (Assert.EnterMultipleScope()) { @@ -57,7 +57,7 @@ public void OnErrorResponse_InvokesDelegateWithResponseContext() } [Test] - public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() + public async Task OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() { const int maxAttempts = 10; var backoff = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); @@ -77,7 +77,7 @@ public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) : new HttpResponseMessage(HttpStatusCode.OK)); - var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + var exception = await Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); using (Assert.EnterMultipleScope()) { diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs index 6e6c44e..81c6c83 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs @@ -204,7 +204,7 @@ public async Task On401Response_ByFailedAuthentication_ThrowsUnauthorizedHttpErr } [Test] - public void On401Response_WithNonReusableContent_ThrowsUnauthorizedHttpErrorWithoutAuthenticating() + public async Task On401Response_WithNonReusableContent_ThrowsUnauthorizedHttpErrorWithoutAuthenticating() { var numberOfInvokes = 0; @@ -220,7 +220,7 @@ public void On401Response_WithNonReusableContent_ThrowsUnauthorizedHttpErrorWith using var content = new StreamContent(new TestStream(seekable: false)); - var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Post, "/protected/resource", content)); + var exception = await Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Post, "/protected/resource", content)); using (Assert.EnterMultipleScope()) { diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs index ab54aa6..c8e8fd6 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs @@ -87,7 +87,7 @@ public async Task On429Response_WithoutRateLimitResetHeader_DoesNotRetry() } [Test] - public void On429Response_WithOutOfRangeRateLimitResetHeader_ThrowsHttpResponseException() + public async Task On429Response_WithOutOfRangeRateLimitResetHeader_ThrowsHttpResponseException() { var tooManyRequestsHandler = new HttpError429Handler(); _client.ErrorHandlers.Add(tooManyRequestsHandler); @@ -102,7 +102,7 @@ public void On429Response_WithOutOfRangeRateLimitResetHeader_ThrowsHttpResponseE return response; }); - var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource")); + var exception = await Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource")); using (Assert.EnterMultipleScope()) { diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs index c853725..201896f 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs @@ -123,7 +123,7 @@ public async Task On503Response_WithoutRetryAfterHeader_RetriesAccordingToDefaul } [Test] - public void On503Response_WithOutOfRangeRetryAfterDate_ThrowsHttpResponseException() + public async Task On503Response_WithOutOfRangeRetryAfterDate_ThrowsHttpResponseException() { var serviceUnavailableHandler = new HttpError503Handler(); _client.ErrorHandlers.Add(serviceUnavailableHandler); @@ -138,7 +138,7 @@ public void On503Response_WithOutOfRangeRetryAfterDate_ThrowsHttpResponseExcepti return response; }); - var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource")); + var exception = await Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource")); using (Assert.EnterMultipleScope()) { diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs index 6f3d4d6..41800a2 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs @@ -54,7 +54,7 @@ public void MaxRetryDelay_WhenNegative_ThrowsArgumentOutOfRangeException() } [Test] - public void OnSuggestedDelayAboveMaxRetryDelay_DoesNotRetry() + public async Task OnSuggestedDelayAboveMaxRetryDelay_DoesNotRetry() { var strategyRequested = false; var handler = new HttpError503Handler @@ -70,7 +70,7 @@ public void OnSuggestedDelayAboveMaxRetryDelay_DoesNotRetry() var attempts = MockServiceUnavailable(TimeSpan.FromHours(1)); - var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource")); + var exception = await Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource")); using (Assert.EnterMultipleScope()) { @@ -155,7 +155,7 @@ public async Task OnLongSuggestedDelay_AfterRetrySessionCreated_RespectsMaxRetry } else { - var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + var exception = await Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); Assert.That(exception.StatusCode, Is.EqualTo(statusCode)); } @@ -167,7 +167,7 @@ public async Task OnLongSuggestedDelay_AfterRetrySessionCreated_RespectsMaxRetry } [Test] - public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() + public async Task OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() { var handler = new HttpError429Handler { @@ -185,7 +185,7 @@ public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() return response; }); - Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource")); + await Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource")); Assert.That(attempts, Is.EqualTo(1)); } diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs index 1425ac2..0c949ab 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs @@ -98,29 +98,29 @@ public async Task PatchAsFormAsync_InvokesHttpClientCorrectly() } [Test] - public void PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + public async Task PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() { _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); - var exception = Assert.ThrowsAsync(() => _restClient.PostAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + var exception = await Assert.ThrowsAsync(() => _restClient.PostAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); } [Test] - public void PutAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + public async Task PutAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() { _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); - var exception = Assert.ThrowsAsync(() => _restClient.PutAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + var exception = await Assert.ThrowsAsync(() => _restClient.PutAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); } [Test] - public void PatchAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + public async Task PatchAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() { _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); - var exception = Assert.ThrowsAsync(() => _restClient.PatchAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + var exception = await Assert.ThrowsAsync(() => _restClient.PatchAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); } @@ -131,11 +131,11 @@ public void PostAsFormAsync_WithNullPayload_ThrowsArgumentNullExceptionSynchrono } [Test] - public void PostAsFormAsync_OnConnectionFailure_ThrowsHttpRequestException() + public async Task PostAsFormAsync_OnConnectionFailure_ThrowsHttpRequestException() { _mockMessageHandler.MockHttpResponse(request => throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable))); - Assert.ThrowsAsync(() => _restClient.PostAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + await Assert.ThrowsAsync(() => _restClient.PostAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); } } } diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index a084321..944ccde 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -128,21 +128,21 @@ public async Task SendAsync_Generic_ReturnsResponseAsObject() } [Test] - public void OnUnsupportedMediaType_ThrowsContentException() + public async Task OnUnsupportedMediaType_ThrowsContentException() { using var responseContent = new StringContent("A,B", Encoding.UTF8, MediaTypeNames.Text.Csv); _mockMessageHandler.MockHttpResponse(HttpStatusCode.OK, responseContent); - Assert.ThrowsAsync(async () => await _client.SendAsync(TestHttpMethod, "/resource")); + await Assert.ThrowsAsync(async () => await _client.SendAsync(TestHttpMethod, "/resource")); } [Test] - public void OnUnsuccessfulStatusCode_WithoutResponseErrorType_ThrowsStandardRestException() + public async Task OnUnsuccessfulStatusCode_WithoutResponseErrorType_ThrowsStandardRestException() { var errorDetails = new TestErrorResponse("You didn't provide the required data!"); _mockMessageHandler.MockHttpResponse(HttpStatusCode.BadRequest, new TestContent(errorDetails)); - var exception = Assert.ThrowsAsync(async () => await _client.SendAsync(TestHttpMethod, "/resource")); + var exception = await Assert.ThrowsAsync(async () => await _client.SendAsync(TestHttpMethod, "/resource")); Assert.That(exception, Is.Not.Null); using (Assert.EnterMultipleScope()) @@ -155,7 +155,7 @@ public void OnUnsuccessfulStatusCode_WithoutResponseErrorType_ThrowsStandardRest } [Test] - public void OnUnsuccessfulStatusCode_ResponseMessageKeepsHeadersButContentIsDisposed() + public async Task OnUnsuccessfulStatusCode_ResponseMessageKeepsHeadersButContentIsDisposed() { _mockMessageHandler.MockHttpResponse(_ => { @@ -164,25 +164,25 @@ public void OnUnsuccessfulStatusCode_ResponseMessageKeepsHeadersButContentIsDisp return response; }); - var exception = Assert.ThrowsAsync(() => _client.SendAsync(TestHttpMethod, "/resource")); + var exception = await Assert.ThrowsAsync(() => _client.SendAsync(TestHttpMethod, "/resource")); Assert.That(exception.ResponseMessage, Is.Not.Null); using (Assert.EnterMultipleScope()) { Assert.That(exception.ResponseMessage.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); Assert.That(exception.ResponseMessage.Headers.GetValues("X-Error-Id"), Is.EqualTo(new[] { "42" })); - Assert.ThrowsAsync(() => exception.ResponseMessage.Content.ReadAsStringAsync()); + await Assert.ThrowsAsync(() => exception.ResponseMessage.Content.ReadAsStringAsync()); } } [Test] - public void OnUnsuccessfulStatusCode_WithResponseErrorType_ThrowsCustomizedRestException() + public async Task OnUnsuccessfulStatusCode_WithResponseErrorType_ThrowsCustomizedRestException() { var errorDetails = new TestErrorResponse("You didn't provide the required data!"); _mockMessageHandler.MockHttpResponse(HttpStatusCode.BadRequest, new TestContent(errorDetails)); _client.ResponseErrorType = typeof(TestErrorResponse); - var exception = Assert.ThrowsAsync(async () => await _client.SendAsync(TestHttpMethod, "/resource")); + var exception = await Assert.ThrowsAsync(async () => await _client.SendAsync(TestHttpMethod, "/resource")); Assert.That(exception, Is.Not.Null); using (Assert.EnterMultipleScope()) @@ -361,7 +361,7 @@ public async Task OnTimeoutCancellation_UsesRetryPolicy() } [Test] - public void OnUnsuccessfulStatusCode_WithOverriddenDecideOnRetry_KeepsRetryBudgetAcrossRetries() + public async Task OnUnsuccessfulStatusCode_WithOverriddenDecideOnRetry_KeepsRetryBudgetAcrossRetries() { const int maxAttempts = 10; @@ -376,7 +376,7 @@ public void OnUnsuccessfulStatusCode_WithOverriddenDecideOnRetry_KeepsRetryBudge BaseAddress = new Uri("http://api.test.com"), }; - var exception = Assert.ThrowsAsync(() => client.SendAsync(HttpMethod.Get, "/resource")); + var exception = await Assert.ThrowsAsync(() => client.SendAsync(HttpMethod.Get, "/resource")); using (Assert.EnterMultipleScope()) { @@ -402,7 +402,7 @@ CancellationToken cancellationToken } [Test] - public void OnCallerCancellation_DoesNotUseRetryPolicy() + public async Task OnCallerCancellation_DoesNotUseRetryPolicy() { var mockRetryPolicy = new Mock(); _client.RetryPolicy = mockRetryPolicy.Object; @@ -417,7 +417,7 @@ public void OnCallerCancellation_DoesNotUseRetryPolicy() throw new TaskCanceledException("The request was canceled.", null, cancellationToken); }); - Assert.ThrowsAsync + await Assert.ThrowsAsync ( async () => await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test"), cancellationToken: cancellationTokenSource.Token) ); diff --git a/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs b/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs index 12672ab..70af240 100644 --- a/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs +++ b/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs @@ -55,13 +55,13 @@ public async Task GetAsStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() [TestCase(false)] [TestCase(true)] - public void GetAsStreamAsync_WhenOpeningBodyFails_DisposesResponseContent(bool failSynchronously) + public async Task GetAsStreamAsync_WhenOpeningBodyFails_DisposesResponseContent(bool failSynchronously) { var failure = new IOException("Body failed."); using var content = new FailingContent(failure, failSynchronously); _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); - var exception = Assert.ThrowsAsync(() => _client.GetAsStreamAsync("/resource")); + var exception = await Assert.ThrowsAsync(() => _client.GetAsStreamAsync("/resource")); using (Assert.EnterMultipleScope()) { @@ -104,13 +104,13 @@ public void GetToStreamAsync_WhenCanceledDuringCopy_StopsCopying() } [Test] - public void DownloadAsync_WhenCopyFails_DisposesDestinationStream() + public async Task DownloadAsync_WhenCopyFails_DisposesDestinationStream() { _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([1, 2, 3, 4]) }); var destination = new FailingStream(); - var exception = Assert.CatchAsync(() => _client.DownloadAsync(HttpMethod.Get, "/resource", null, _ => destination)); + var exception = await Assert.CatchAsync(() => _client.DownloadAsync(HttpMethod.Get, "/resource", null, _ => destination)); using (Assert.EnterMultipleScope()) { diff --git a/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs index 8223f77..088be8c 100644 --- a/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs @@ -168,7 +168,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries [TestCase("gzip")] [TestCase("deflate")] - public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry(string encoding) + public async Task SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry(string encoding) { var payload = new PlainModel { Name = "XML Test" }; var attempts = 0; @@ -194,7 +194,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry using var content = new XmlContent(payload); using var compressedContent = CompressContent(content, encoding); - Assert.ThrowsAsync + await Assert.ThrowsAsync ( Is.InstanceOf(), async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) diff --git a/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs b/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs index 4afe0dc..0a3b1d9 100644 --- a/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs +++ b/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs @@ -157,7 +157,7 @@ public async Task DataContractSettings_ApplyInBothDirections() { Assert.That(written, Does.Contain("(() => Read(new XmlFormatter(), written, typeof(ContractModel))); + await Assert.ThrowsAsync(() => Read(new XmlFormatter(), written, typeof(ContractModel))); } } From ef5a1a2a6aa8fd3d0239275a0cefaf469ba45630 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Wed, 7 Oct 2026 22:17:23 +0800 Subject: [PATCH 6/6] Update .NET Framework test packages to NUnit 5 Bring the net48 test project to the test package versions of the other test projects: Microsoft.NET.Test.Sdk 18.10.1, NUnit 5.0.0 and NUnit3TestAdapter 6.3.0. NUnit 5 returns a Task from Assert.ThrowsAsync that must be awaited for the assertion to be evaluated. Await the three calls and make their tests async. The one inside Assert.EnterMultipleScope compiled without an await but would not have checked anything. --- .../Kampute.HttpClient.NetFramework.Test.csproj | 6 +++--- .../TargetSpecificBehaviorTests.cs | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj index fb10b1f..ec83e15 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj +++ b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj @@ -10,9 +10,9 @@ - - - + + + diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs index feb3376..2500139 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs @@ -67,18 +67,18 @@ public async Task HttpVerbPatch_SendsPatchMethod() } [Test] - public void PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + public async Task PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() { using var handler = new TestHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); using var client = CreateClient(handler); - var exception = Assert.ThrowsAsync(() => client.PostAsFormAsync("/resource", [new KeyValuePair("name", "value")])); + var exception = await Assert.ThrowsAsync(() => client.PostAsFormAsync("/resource", [new KeyValuePair("name", "value")])); - Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + Assert.That(exception!.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); } [Test] - public void OnErrorResponse_ResponseMessageKeepsHeadersButContentIsDisposed() + public async Task OnErrorResponse_ResponseMessageKeepsHeadersButContentIsDisposed() { using var handler = new TestHttpMessageHandler(_ => { @@ -88,14 +88,14 @@ public void OnErrorResponse_ResponseMessageKeepsHeadersButContentIsDisposed() }); using var client = CreateClient(handler); - var exception = Assert.ThrowsAsync(() => client.SendAsync(HttpMethod.Get, "/resource")); + var exception = await Assert.ThrowsAsync(() => client.SendAsync(HttpMethod.Get, "/resource")); - Assert.That(exception.ResponseMessage, Is.Not.Null); + Assert.That(exception!.ResponseMessage, Is.Not.Null); using (Assert.EnterMultipleScope()) { Assert.That(exception.ResponseMessage!.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); Assert.That(exception.ResponseMessage.Headers.GetValues("X-Error-Id"), Is.EqualTo(new[] { "42" })); - Assert.ThrowsAsync(() => exception.ResponseMessage.Content.ReadAsStringAsync()); + await Assert.ThrowsAsync(() => exception.ResponseMessage.Content.ReadAsStringAsync()); } }