diff --git a/README.md b/README.md index 65aa0c3..a518cdd 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ mvcBuilder.AddHttpExceptions(options => Set the ExceptionMapper collection that will be used during mapping. You can override and/or add ExceptionMappers for specific exception types. The ExceptionMappers are called in order so make sure you add them in the right order. -By default there is one ExceptionMapper configured, that ExceptionMapper catches all exceptions. +By default there is one ExceptionMapper configured, that ExceptionMapper catches all exceptions. ``` csharp mvcBuilder.AddHttpExceptions(options => @@ -149,6 +149,22 @@ mvcBuilder.AddHttpExceptions(options => }); ``` +### Serialization helper methods +Serialize the HTTP content to ProblemDetails. +``` csharp +ProblemDetails problemDetails = response.Content.ReadAsProblemDetails(); +``` + +Try to get the exception details from the ProblemDetails. +``` csharp +problemDetails.TryGetExceptionDetails(out SerializableException exception); +``` + +Try to get the errors dictionary from the ProblemDetails. +``` csharp +problemDetails.TryGetErrors(out IDictionary errors); +``` + ### Sample project using HttpExceptions middleware See the `samples/Opw.HttpExceptions.AspNetCore.Sample` project for a sample implementation. This project contains examples on how to use the HttpExceptions middleware. diff --git a/src/Opw.HttpExceptions.AspNetCore/HttpContentExtensions.cs b/src/Opw.HttpExceptions.AspNetCore/HttpContentExtensions.cs new file mode 100644 index 0000000..9810cdd --- /dev/null +++ b/src/Opw.HttpExceptions.AspNetCore/HttpContentExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Opw.HttpExceptions.AspNetCore.Serialization; + +namespace Opw.HttpExceptions.AspNetCore +{ + /// + /// Provides extension methods for HttpContent. + /// + public static class HttpContentExtensions + { + /// + /// Serialize the HTTP content to ProblemDetails as an asynchronous operation. + /// + /// The HTTP content. + public static ProblemDetails ReadAsProblemDetails(this HttpContent content) + { + _ = content ?? throw new ArgumentNullException(nameof(content)); + + if (!content.Headers.ContentType.MediaType.Equals("application/problem+json", StringComparison.OrdinalIgnoreCase)) + { + throw new SerializationException("HttpContent is not of type \"application/problem+json\"."); + } + + var str = content.ReadAsStringAsync().Result; + var converter = new ProblemDetailsJsonConverter(); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(str)); + var problemDetails = converter.Read(ref reader, typeof(ProblemDetails), new JsonSerializerOptions()); + + return problemDetails; + } + } +} diff --git a/src/Opw.HttpExceptions.AspNetCore/HttpExceptionsOptionsExtensions.cs b/src/Opw.HttpExceptions.AspNetCore/HttpExceptionsOptionsExtensions.cs index 71bebd8..1d643d9 100644 --- a/src/Opw.HttpExceptions.AspNetCore/HttpExceptionsOptionsExtensions.cs +++ b/src/Opw.HttpExceptions.AspNetCore/HttpExceptionsOptionsExtensions.cs @@ -21,7 +21,8 @@ public static void ExceptionMapper(this HttpExcept { if (options.ExceptionMapperDescriptors.ContainsKey(typeof(TException))) { - options.ExceptionMapperDescriptors[typeof(TException)] = new ExceptionMapperDescriptor { + options.ExceptionMapperDescriptors[typeof(TException)] = new ExceptionMapperDescriptor + { Type = typeof(TExceptionMapper), Arguments = arguments }; diff --git a/src/Opw.HttpExceptions.AspNetCore/ProblemDetailsExtensions.cs b/src/Opw.HttpExceptions.AspNetCore/ProblemDetailsExtensions.cs new file mode 100644 index 0000000..783a362 --- /dev/null +++ b/src/Opw.HttpExceptions.AspNetCore/ProblemDetailsExtensions.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Mvc; +using Opw.HttpExceptions.AspNetCore.Serialization; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; + +namespace Opw.HttpExceptions.AspNetCore +{ + /// + /// Provides extension methods for ProblemDetails. + /// + public static class ProblemDetailsExtensions + { + /// + /// Try to get the exception details from the ProblemDetails. + /// + /// ProblemDetails to get the exception from. + /// When this method returns, the exception from the ProblemDetails, if the ProblemDetails contains an exception; otherwise, null. + /// true if the ProblemDetails contains an exception; otherwise, false. + public static bool TryGetExceptionDetails(this ProblemDetails problemDetails, out SerializableException exception) + { + if (problemDetails.Extensions.TryGetValue(nameof(ProblemDetailsExtensionMembers.ExceptionDetails).ToCamelCase(), out var value)) + return value.TryParseSerializableException(out exception); + + exception = null; + return false; + } + + /// + /// Try to parse a SerializableException from an object. + /// + /// The object to parse the SerializableException from. + /// When this method returns, the exception from the object, if the object can be parsed to an SerializableException; otherwise, null. + /// true if the object can be parsed to an SerializableException; otherwise, false. + public static bool TryParseSerializableException(this object value, out SerializableException exception) + { + exception = null; + + if (value is SerializableException serializableException) + exception = serializableException; + + if (value is JsonElement jsonElement) + { + var json = jsonElement.GetRawText(); + exception = json.ReadAsSerializableException(); + } + + return exception != null; + } + + /// + /// Try to get the errors dictionary from the ProblemDetails. + /// + /// ProblemDetails to get the errors dictionary from. + /// When this method returns, the errors dictionary from the ProblemDetails, if the ProblemDetails contains errors dictionary; otherwise, null. + /// true if the ProblemDetails contains errors dictionary; otherwise, false. + public static bool TryGetErrors(this ProblemDetails problemDetails, out IDictionary errors) + { + if (problemDetails.Extensions.TryGetValue(nameof(ProblemDetailsExtensionMembers.Errors).ToCamelCase(), out var value)) + return value.TryParseErrors(out errors); + + errors = null; + return false; + } + + /// + /// Try to parse a errors dictionary from an object. + /// + /// The object to parse the errors dictionary from. + /// When this method returns, the errors dictionary from the object, if the object can be parsed to an errors dictionary; otherwise, null. + /// true if the object can be parsed to an errors dictionary; otherwise, false. + public static bool TryParseErrors(this object value, out IDictionary errors) + { + errors = null; + +#pragma warning disable RCS1220 // Use pattern matching instead of combination of 'is' operator and cast operator. + if (value is IDictionary) + errors = (IDictionary)value; +#pragma warning restore RCS1220 // Use pattern matching instead of combination of 'is' operator and cast operator. + + return errors != null; + } + } +} diff --git a/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsJsonConverter.cs b/src/Opw.HttpExceptions.AspNetCore/Serialization/ProblemDetailsJsonConverter.cs similarity index 98% rename from tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsJsonConverter.cs rename to src/Opw.HttpExceptions.AspNetCore/Serialization/ProblemDetailsJsonConverter.cs index fbb5e82..b7770d9 100644 --- a/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsJsonConverter.cs +++ b/src/Opw.HttpExceptions.AspNetCore/Serialization/ProblemDetailsJsonConverter.cs @@ -3,7 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Opw.HttpExceptions.AspNetCore +namespace Opw.HttpExceptions.AspNetCore.Serialization { // https://github.com/aspnet/AspNetCore/blob/a99ab25700e6c8a58ca75ee59621b8b671431706/src/Mvc/Mvc.Core/src/Infrastructure/ProblemDetailsJsonConverter.cs internal class ProblemDetailsJsonConverter : JsonConverter diff --git a/src/Opw.HttpExceptions.AspNetCore/Serialization/SerializableExceptionJsonConverter.cs b/src/Opw.HttpExceptions.AspNetCore/Serialization/SerializableExceptionJsonConverter.cs new file mode 100644 index 0000000..181c8db --- /dev/null +++ b/src/Opw.HttpExceptions.AspNetCore/Serialization/SerializableExceptionJsonConverter.cs @@ -0,0 +1,117 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Opw.HttpExceptions.AspNetCore.Serialization +{ + internal class SerializableExceptionJsonConverter : JsonConverter + { + private static readonly JsonEncodedText Type = JsonEncodedText.Encode("type"); + private static readonly JsonEncodedText HelpLink = JsonEncodedText.Encode("helpLink"); + private static readonly JsonEncodedText HResult = JsonEncodedText.Encode("hResult"); + private static readonly JsonEncodedText Message = JsonEncodedText.Encode("message"); + private static readonly JsonEncodedText Source = JsonEncodedText.Encode("source"); + private static readonly JsonEncodedText StackTrace = JsonEncodedText.Encode("stackTrace"); + private static readonly JsonEncodedText Data = JsonEncodedText.Encode("data"); + private static readonly JsonEncodedText InnerException = JsonEncodedText.Encode("innerException"); + + public override SerializableException Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var serializableException = new SerializableException(); + + if (!reader.Read()) + { + throw new JsonException("UnexpectedJsonEnd"); + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + ReadValue(ref reader, serializableException, options); + } + + if (reader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException("UnexpectedJsonEnd"); + } + + return serializableException; + } + + public override void Write(Utf8JsonWriter writer, SerializableException value, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + internal static void ReadValue(ref Utf8JsonReader reader, SerializableException value, JsonSerializerOptions options) + { + if (TryReadStringProperty(ref reader, Type, out var propertyValue)) + { + value.Type = propertyValue; + } + else if (TryReadStringProperty(ref reader, HelpLink, out propertyValue)) + { + value.HelpLink = propertyValue; + } + else if (TryReadStringProperty(ref reader, Message, out propertyValue)) + { + value.Message = propertyValue; + } + else if (TryReadStringProperty(ref reader, Source, out propertyValue)) + { + value.Source = propertyValue; + } + else if (TryReadStringProperty(ref reader, StackTrace, out propertyValue)) + { + value.StackTrace = propertyValue; + } + else if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals(HResult.EncodedUtf8Bytes)) + { + reader.Read(); + if (reader.TokenType == JsonTokenType.Null) + { + // Nothing to do here. + } + else + { + value.HResult = reader.GetInt32(); + } + } + else + { + var propertyName = reader.GetString(); + + string json; + using (var document = JsonDocument.ParseValue(ref reader)) + { + json = document.RootElement.Clone().GetRawText(); + } + + if (propertyName == Data.ToString()) + { + // TODO: Data property has not been implemented + } + else if (propertyName == InnerException.ToString()) + { + try + { + value.InnerException = json.ReadAsSerializableException(); + } + catch { } + } + } + } + + internal static bool TryReadStringProperty(ref Utf8JsonReader reader, JsonEncodedText propertyName, out string value) + { + if (reader.TokenType != JsonTokenType.PropertyName || !reader.ValueTextEquals(propertyName.EncodedUtf8Bytes)) + { + value = default; + return false; + } + + reader.Read(); + value = reader.GetString(); + return true; + } + } +} \ No newline at end of file diff --git a/src/Opw.HttpExceptions.AspNetCore/StringExtensions.cs b/src/Opw.HttpExceptions.AspNetCore/StringExtensions.cs index 4c827f0..a69f1a0 100644 --- a/src/Opw.HttpExceptions.AspNetCore/StringExtensions.cs +++ b/src/Opw.HttpExceptions.AspNetCore/StringExtensions.cs @@ -1,10 +1,11 @@ +using Opw.HttpExceptions.AspNetCore.Serialization; using System.Globalization; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; namespace Opw.HttpExceptions.AspNetCore { - //TODO: create a tools package for these extensions? internal static class StringExtensions { internal static string ToCamelCase(this string s) @@ -49,5 +50,12 @@ internal static string RemoveDiacritics(this string s) return stringBuilder.ToString().Normalize(NormalizationForm.FormC); } + + internal static SerializableException ReadAsSerializableException(this string json) + { + var converter = new SerializableExceptionJsonConverter(); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + return converter.Read(ref reader, typeof(SerializableException), new JsonSerializerOptions()); + } } } diff --git a/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpContentExtensions.cs b/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpContentExtensions.cs index 3d0f27f..5c03640 100644 --- a/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpContentExtensions.cs +++ b/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpContentExtensions.cs @@ -1,7 +1,7 @@ using System.Net.Http; using System.Text; +using System.Text.Json; using System.Threading.Tasks; -using Newtonsoft.Json; namespace Opw.HttpExceptions.AspNetCore { @@ -10,13 +10,14 @@ public static class HttpContentExtensions public static async Task ReadAsAsync(this HttpContent content) { var str = await content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(str); + // WARNING: Newtonsoft can only be used here because this is a test project + return Newtonsoft.Json.JsonConvert.DeserializeObject(str); } - public static StringContent ToJsonContent(this object obj) + public static StringContent ToJsonContent(this object obj, string mediaType = "application/json") { - var str = JsonConvert.SerializeObject(obj); - return new StringContent(str, Encoding.UTF8, "application/json"); + var str = JsonSerializer.Serialize(obj, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + return new StringContent(str, Encoding.UTF8, mediaType); } } } diff --git a/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpContentExtensionsTests.cs b/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpContentExtensionsTests.cs new file mode 100644 index 0000000..921a54e --- /dev/null +++ b/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpContentExtensionsTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using System; +using Xunit; +using System.Net; + +namespace Opw.HttpExceptions.AspNetCore +{ + public class HttpContentExtensionsTests + { + [Fact] + public void ReadAsProblemDetails_Should_ReturnProblemDetails() + { + ApplicationException applicationException; + try + { + throw new ApplicationException("Error!"); + } + catch (ApplicationException ex) + { + applicationException = ex; + } + + var mapper = TestHelper.CreateProblemDetailsExceptionMapper(true); + var actionResult = mapper.Map(applicationException, new DefaultHttpContext()); + + actionResult.Should().BeOfType(); + var problemDetailsResult = (ProblemDetailsResult)actionResult; + var jsonContent = problemDetailsResult.Value.ToJsonContent("application/problem+json"); + + var result = jsonContent.ReadAsProblemDetails(); + + result.ShouldNotBeNull(HttpStatusCode.InternalServerError); + result.Title.Should().Be("Application"); + result.Detail.Should().Be("Error!"); + result.Type.Should().Be("error:application"); + result.Instance.Should().BeNull(); + + var exception = result.ShouldHaveExceptionDetails(); + + exception.Type.Should().Be("ApplicationException"); + exception.Message.Should().Be("Error!"); + } + } +} diff --git a/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpResponseMessageExtensions.cs b/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpResponseMessageExtensions.cs index d0f781e..62be942 100644 --- a/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpResponseMessageExtensions.cs +++ b/tests/Opw.HttpExceptions.AspNetCore.Tests/HttpResponseMessageExtensions.cs @@ -12,10 +12,7 @@ public static ProblemDetails ShouldBeProblemDetails(this HttpResponseMessage res response.StatusCode.Should().Be(statusCode); response.Content.Headers.ContentType.MediaType.Should().Be("application/problem+json"); - var str = response.Content.ReadAsStringAsync().Result; - var converter = new ProblemDetailsJsonConverter(); - var reader = new System.Text.Json.Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(str)); - var problemDetails = converter.Read(ref reader, typeof(ProblemDetails), new JsonOptions().JsonSerializerOptions); + var problemDetails = response.Content.ReadAsProblemDetails(); problemDetails.Should().NotBeNull(); problemDetails.Status.Should().Be((int)statusCode); diff --git a/tests/Opw.HttpExceptions.AspNetCore.Tests/Opw.HttpExceptions.AspNetCore.Tests.csproj b/tests/Opw.HttpExceptions.AspNetCore.Tests/Opw.HttpExceptions.AspNetCore.Tests.csproj index 40ec14a..4d04306 100644 --- a/tests/Opw.HttpExceptions.AspNetCore.Tests/Opw.HttpExceptions.AspNetCore.Tests.csproj +++ b/tests/Opw.HttpExceptions.AspNetCore.Tests/Opw.HttpExceptions.AspNetCore.Tests.csproj @@ -21,10 +21,6 @@ - - - - diff --git a/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsExtensions.cs b/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsExtensions.cs index 77e2e81..c5c0420 100644 --- a/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsExtensions.cs +++ b/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsExtensions.cs @@ -1,7 +1,5 @@ using FluentAssertions; using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; using System.Net; namespace Opw.HttpExceptions.AspNetCore @@ -29,64 +27,5 @@ public static SerializableException ShouldHaveExceptionDetails(this ProblemDetai return exception; } - - public static bool TryGetExceptionDetails(this ProblemDetails problemDetails, out SerializableException exception) - { - if (problemDetails.Extensions.TryGetValue(nameof(ProblemDetailsExtensionMembers.ExceptionDetails).ToCamelCase(), out var value)) - return value.TryParseSerializableException(out exception); - - exception = null; - return false; - } - - public static bool TryParseSerializableException(this object value, out SerializableException exception) - { - exception = null; - - if (value is SerializableException serializableException) - exception = serializableException; - if (value is Newtonsoft.Json.Linq.JToken jTokens) - exception = jTokens.ToObject(); - if (value is System.Text.Json.JsonElement jsonElement) - { - var str = jsonElement.GetRawText(); - - var settings = new Newtonsoft.Json.JsonSerializerSettings - { - ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver - { - IgnoreSerializableAttribute = true, - IgnoreSerializableInterface = true - } - }; - - exception = Newtonsoft.Json.JsonConvert.DeserializeObject(str, settings); - } - - return exception != null; - } - - public static bool TryGetErrors(this ProblemDetails problemDetails, out IDictionary errors) - { - if (problemDetails.Extensions.TryGetValue(nameof(ProblemDetailsExtensionMembers.Errors).ToCamelCase(), out var value)) - return value.TryParseErrors(out errors); - - errors = null; - return false; - } - - public static bool TryParseErrors(this object value, out IDictionary errors) - { - errors = null; - -#pragma warning disable RCS1220 // Use pattern matching instead of combination of 'is' operator and cast operator. - if (value is IDictionary) - errors = (IDictionary)value; - if (value is Newtonsoft.Json.Linq.JToken) - errors = ((Newtonsoft.Json.Linq.JToken)value).ToObject>(); -#pragma warning restore RCS1220 // Use pattern matching instead of combination of 'is' operator and cast operator. - - return errors != null; - } } } diff --git a/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsExtensionsTests.cs b/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsExtensionsTests.cs index 5ba5d2d..e99ac62 100644 --- a/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsExtensionsTests.cs +++ b/tests/Opw.HttpExceptions.AspNetCore.Tests/ProblemDetailsExtensionsTests.cs @@ -1,10 +1,9 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; -using Newtonsoft.Json.Linq; using System; using Xunit; -namespace Opw.HttpExceptions.AspNetCore +namespace Opw.HttpExceptions.AspNetCore.Serialization { public class ProblemDetailsExtensionsTests { @@ -50,16 +49,5 @@ public void TryParseSerializableException_Should_ReturnTrue_ForTypeOfExceptionIn result.Should().BeTrue(); parsedException.Should().NotBeNull(); } - - [Fact] - public void TryParseSerializableException_Should_ReturnTrue_ForTypeOfJToken() - { - var exception = JToken.FromObject(new SerializableException(new ApplicationException())); - - var result = exception.TryParseSerializableException(out var parsedException); - - result.Should().BeTrue(); - parsedException.Should().NotBeNull(); - } } } diff --git a/tests/Opw.HttpExceptions.AspNetCore.Tests/Serialization/ProblemDetailsJsonConverterTest.cs b/tests/Opw.HttpExceptions.AspNetCore.Tests/Serialization/ProblemDetailsJsonConverterTest.cs new file mode 100644 index 0000000..fb414d8 --- /dev/null +++ b/tests/Opw.HttpExceptions.AspNetCore.Tests/Serialization/ProblemDetailsJsonConverterTest.cs @@ -0,0 +1,161 @@ +using Microsoft.AspNetCore.Mvc; +using System.IO; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace Opw.HttpExceptions.AspNetCore.Serialization +{ + // https://github.com/dotnet/aspnetcore/blob/b795ac3546eb3e2f47a01a64feb3020794ca33bb/src/Mvc/Mvc.Core/test/Infrastructure/ProblemDetailsJsonConverterTest.cs + public class ProblemDetailsJsonConverterTest + { + private static JsonSerializerOptions JsonSerializerOptions => new JsonOptions().JsonSerializerOptions; + + [Fact] + public void Read_ThrowsIfJsonIsIncomplete() + { + var json = "{"; + var converter = new ProblemDetailsJsonConverter(); + + var ex = Record.Exception(() => + { + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + converter.Read(ref reader, typeof(ProblemDetails), JsonSerializerOptions); + }); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public void Read_Works() + { + var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4"; + var title = "Not found"; + var status = 404; + var detail = "Product not found"; + var instance = "http://example.com/products/14"; + var traceId = "|37dd3dd5-4a9619f953c40a16."; + var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"detail\":\"{detail}\", \"instance\":\"{instance}\",\"traceId\":\"{traceId}\"}}"; + var converter = new ProblemDetailsJsonConverter(); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + + var problemDetails = converter.Read(ref reader, typeof(ProblemDetails), JsonSerializerOptions); + + Assert.Equal(type, problemDetails.Type); + Assert.Equal(title, problemDetails.Title); + Assert.Equal(status, problemDetails.Status); + Assert.Equal(instance, problemDetails.Instance); + Assert.Equal(detail, problemDetails.Detail); + Assert.Collection( + problemDetails.Extensions, + kvp => + { + Assert.Equal("traceId", kvp.Key); + Assert.Equal(traceId, kvp.Value.ToString()); + }); + } + + [Fact] + public void Read_UsingJsonSerializerWorks() + { + var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4"; + var title = "Not found"; + var status = 404; + var detail = "Product not found"; + var instance = "http://example.com/products/14"; + var traceId = "|37dd3dd5-4a9619f953c40a16."; + var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"detail\":\"{detail}\", \"instance\":\"{instance}\",\"traceId\":\"{traceId}\"}}"; + + var problemDetails = JsonSerializer.Deserialize(json, JsonSerializerOptions); + + Assert.Equal(type, problemDetails.Type); + Assert.Equal(title, problemDetails.Title); + Assert.Equal(status, problemDetails.Status); + Assert.Equal(instance, problemDetails.Instance); + Assert.Equal(detail, problemDetails.Detail); + Assert.Collection( + problemDetails.Extensions, + kvp => + { + Assert.Equal("traceId", kvp.Key); + Assert.Equal(traceId, kvp.Value.ToString()); + }); + } + + [Fact] + public void Read_WithSomeMissingValues_Works() + { + var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4"; + var title = "Not found"; + var status = 404; + var traceId = "|37dd3dd5-4a9619f953c40a16."; + var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"traceId\":\"{traceId}\"}}"; + var converter = new ProblemDetailsJsonConverter(); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + + var problemDetails = converter.Read(ref reader, typeof(ProblemDetails), JsonSerializerOptions); + + Assert.Equal(type, problemDetails.Type); + Assert.Equal(title, problemDetails.Title); + Assert.Equal(status, problemDetails.Status); + Assert.Collection( + problemDetails.Extensions, + kvp => + { + Assert.Equal("traceId", kvp.Key); + Assert.Equal(traceId, kvp.Value.ToString()); + }); + } + + [Fact] + public void Write_Works() + { + var traceId = "|37dd3dd5-4a9619f953c40a16."; + var value = new ProblemDetails + { + Title = "Not found", + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4", + Status = 404, + Detail = "Product not found", + Instance = "http://example.com/products/14", + Extensions = + { + { "traceId", traceId }, + { "some-data", new[] { "value1", "value2" } } + } + }; + var expected = $"{{\"type\":\"{JsonEncodedText.Encode(value.Type)}\",\"title\":\"{value.Title}\",\"status\":{value.Status},\"detail\":\"{value.Detail}\",\"instance\":\"{JsonEncodedText.Encode(value.Instance)}\",\"traceId\":\"{traceId}\",\"some-data\":[\"value1\",\"value2\"]}}"; + var converter = new ProblemDetailsJsonConverter(); + var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream)) + { + converter.Write(writer, value, JsonSerializerOptions); + } + + var actual = Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal(expected, actual); + } + + [Fact] + public void Write_WithSomeMissingContent_Works() + { + var value = new ProblemDetails + { + Title = "Not found", + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4", + Status = 404, + }; + var expected = $"{{\"type\":\"{JsonEncodedText.Encode(value.Type)}\",\"title\":\"{value.Title}\",\"status\":{value.Status}}}"; + var converter = new ProblemDetailsJsonConverter(); + var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream)) + { + converter.Write(writer, value, JsonSerializerOptions); + } + + var actual = Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal(expected, actual); + } + } +}