From 5ca67c47288e7390b707c8c1fe2ba07e09154180 Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Thu, 8 Aug 2024 10:36:40 -0500 Subject: [PATCH] fix ProblemDetail Deserialize, add a QueryString IEnumerable overload --- src/FluentRest/ProblemDetails.cs | 47 ++++++-- src/FluentRest/ProblemDetailsConverter.cs | 105 ------------------ src/FluentRest/QueryBuilder.cs | 28 +++++ src/FluentRest/UrlBuilder.cs | 25 +++++ test/FluentRest.Tests/FluentRest.Tests.csproj | 1 + test/FluentRest.Tests/ProblemDetailsTests.cs | 80 +++++++++++++ test/FluentRest.Tests/QueryBuilderTest.cs | 14 +++ 7 files changed, 184 insertions(+), 116 deletions(-) delete mode 100644 src/FluentRest/ProblemDetailsConverter.cs create mode 100644 test/FluentRest.Tests/ProblemDetailsTests.cs diff --git a/src/FluentRest/ProblemDetails.cs b/src/FluentRest/ProblemDetails.cs index e59a948..c69789b 100644 --- a/src/FluentRest/ProblemDetails.cs +++ b/src/FluentRest/ProblemDetails.cs @@ -1,11 +1,12 @@ using System.Text.Json.Serialization; +#nullable enable + namespace FluentRest; /// /// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807. /// -[JsonConverter(typeof(ProblemDetailsConverter))] public class ProblemDetails { /// @@ -14,23 +15,31 @@ public class ProblemDetails public const string ContentType = "application/problem+json"; /// - /// A URI reference that identifies the problem type. + /// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when + /// dereferenced, it provide human-readable documentation for the problem type + /// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be + /// "about:blank". /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-5)] [JsonPropertyName("type")] - public string Type { get; set; } + public string? Type { get; set; } /// - /// A short, human-readable summary of the problem type. + /// A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence + /// of the problem, except for purposes of localization(e.g., using proactive content negotiation; + /// see[RFC7231], Section 3.4). /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-4)] [JsonPropertyName("title")] - public string Title { get; set; } + public string? Title { get; set; } /// - /// The HTTP status code generated by the origin server for this occurrence of the problem. + /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-3)] [JsonPropertyName("status")] public int? Status { get; set; } @@ -38,19 +47,35 @@ public class ProblemDetails /// A human-readable explanation specific to this occurrence of the problem. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-2)] [JsonPropertyName("detail")] - public string Detail { get; set; } + public string? Detail { get; set; } /// - /// A URI reference that identifies the specific occurrence of the problem. + /// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-1)] [JsonPropertyName("instance")] - public string Instance { get; set; } + public string? Instance { get; set; } + + /// + /// Gets the validation errors associated with this instance of problem details + /// + [JsonPropertyName("errors")] + public IDictionary Errors { get; set; } = new Dictionary(StringComparer.Ordinal); /// - /// Problem type definitions MAY extend the problem details object with additional members. + /// Gets the for extension members. + /// + /// Problem type definitions MAY extend the problem details object with additional members. Extension members appear in the same namespace as + /// other members of a problem type. + /// /// + /// + /// The round-tripping behavior for is determined by the implementation of the Input \ Output formatters. + /// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters. + /// [JsonExtensionData] - public IDictionary Extensions { get; } = new Dictionary(StringComparer.Ordinal); + public IDictionary Extensions { get; set; } = new Dictionary(StringComparer.Ordinal); } diff --git a/src/FluentRest/ProblemDetailsConverter.cs b/src/FluentRest/ProblemDetailsConverter.cs deleted file mode 100644 index 54d0ffc..0000000 --- a/src/FluentRest/ProblemDetailsConverter.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace FluentRest; - -internal sealed class ProblemDetailsConverter : JsonConverter -{ - private static readonly JsonEncodedText ProblemType = JsonEncodedText.Encode("type"); - private static readonly JsonEncodedText Title = JsonEncodedText.Encode("title"); - private static readonly JsonEncodedText Status = JsonEncodedText.Encode("status"); - private static readonly JsonEncodedText Detail = JsonEncodedText.Encode("detail"); - private static readonly JsonEncodedText Instance = JsonEncodedText.Encode("instance"); - - public override ProblemDetails Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var problemDetails = new ProblemDetails(); - - if (reader.TokenType != JsonTokenType.StartObject) - throw new JsonException("Unexcepted end when reading JSON."); - - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - ReadValue(ref reader, problemDetails, options); - - if (reader.TokenType != JsonTokenType.EndObject) - throw new JsonException("Unexcepted end when reading JSON."); - - return problemDetails; - } - - public override void Write(Utf8JsonWriter writer, ProblemDetails value, JsonSerializerOptions options) - { - writer.WriteStartObject(); - WriteProblemDetails(writer, value, options); - writer.WriteEndObject(); - } - - internal static void ReadValue(ref Utf8JsonReader reader, ProblemDetails value, JsonSerializerOptions options) - { - if (TryReadStringProperty(ref reader, ProblemType, out var propertyValue)) - { - value.Type = propertyValue; - } - else if (TryReadStringProperty(ref reader, Title, out propertyValue)) - { - value.Title = propertyValue; - } - else if (TryReadStringProperty(ref reader, Detail, out propertyValue)) - { - value.Detail = propertyValue; - } - else if (TryReadStringProperty(ref reader, Instance, out propertyValue)) - { - value.Instance = propertyValue; - } - else if (reader.ValueTextEquals(Status.EncodedUtf8Bytes)) - { - reader.Read(); - if (reader.TokenType != JsonTokenType.Null) - value.Status = reader.GetInt32(); - } - else - { - var key = reader.GetString()!; - reader.Read(); - value.Extensions[key] = JsonSerializer.Deserialize(ref reader, typeof(object), options); - } - } - - internal static bool TryReadStringProperty(ref Utf8JsonReader reader, JsonEncodedText propertyName, out string value) - { - if (!reader.ValueTextEquals(propertyName.EncodedUtf8Bytes)) - { - value = default; - return false; - } - - reader.Read(); - value = reader.GetString()!; - return true; - } - - internal static void WriteProblemDetails(Utf8JsonWriter writer, ProblemDetails value, JsonSerializerOptions options) - { - if (value.Type != null) - writer.WriteString(ProblemType, value.Type); - - if (value.Title != null) - writer.WriteString(Title, value.Title); - - if (value.Status != null) - writer.WriteNumber(Status, value.Status.Value); - - if (value.Detail != null) - writer.WriteString(Detail, value.Detail); - - if (value.Instance != null) - writer.WriteString(Instance, value.Instance); - - foreach (var kvp in value.Extensions) - { - writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, kvp.Value?.GetType() ?? typeof(object), options); - } - } -} diff --git a/src/FluentRest/QueryBuilder.cs b/src/FluentRest/QueryBuilder.cs index 9470fff..1866132 100644 --- a/src/FluentRest/QueryBuilder.cs +++ b/src/FluentRest/QueryBuilder.cs @@ -374,6 +374,34 @@ public TBuilder QueryString(string name, TValue value) return QueryString(name, v); } + /// + /// Appends the specified and to the request Uri. + /// + /// The type of the value. + /// The query parameter name. + /// The query parameter values. + /// + /// A fluent request builder. + /// + /// is . + public TBuilder QueryString(string name, IEnumerable values) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + if (values == null) + return this as TBuilder; + + foreach (var value in values) + { + var v = value != null ? value.ToString() : string.Empty; + QueryString(name, v); + } + + return this as TBuilder; + } + + /// /// Appends the specified and to the request Uri if the specified is true. /// diff --git a/src/FluentRest/UrlBuilder.cs b/src/FluentRest/UrlBuilder.cs index d3574f0..e2f3f69 100644 --- a/src/FluentRest/UrlBuilder.cs +++ b/src/FluentRest/UrlBuilder.cs @@ -454,6 +454,31 @@ public UrlBuilder AppendQuery(string name, TValue value) return AppendQuery(name, v); } + /// + /// Appends the query string name and values to the current url. + /// + /// The type of the value. + /// The query string name. + /// The query string values. + /// + /// name is null + public UrlBuilder AppendQuery(string name, IEnumerable values) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + if (values == null) + return this; + + foreach (var value in values) + { + var v = value != null ? value.ToString() : string.Empty; + AppendQuery(name, v); + } + + return this; + } + /// /// Conditionally appends the query string name and value to the current url if the specified is true. /// diff --git a/test/FluentRest.Tests/FluentRest.Tests.csproj b/test/FluentRest.Tests/FluentRest.Tests.csproj index 7fac10c..cb2280e 100644 --- a/test/FluentRest.Tests/FluentRest.Tests.csproj +++ b/test/FluentRest.Tests/FluentRest.Tests.csproj @@ -2,6 +2,7 @@ net6.0;net7.0;net8.0 false + Latest diff --git a/test/FluentRest.Tests/ProblemDetailsTests.cs b/test/FluentRest.Tests/ProblemDetailsTests.cs new file mode 100644 index 0000000..b620e88 --- /dev/null +++ b/test/FluentRest.Tests/ProblemDetailsTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using FluentAssertions; + +using Xunit; + +namespace FluentRest.Tests; + +public class ProblemDetailsTests +{ + [Fact] + public void DeserializeBadRequestValidation() + { + var options = CreateOptions(); + + var json = @"{ + ""type"": ""https://tools.ietf.org/html/rfc9110#section-15.5.1"", + ""title"": ""One or more validation errors occurred."", + ""status"": 400, + ""errors"": { + ""caseManagerIds"": [ + ""The value 'System.Collections.Generic.List`1[System.Int32]' is not valid."" + ] + }, + ""traceId"": ""00-894a84e8e9621fb8fcabcb2911e6d51c-5a4777d830f30cff-00"" +}"; + var problemDetails = JsonSerializer.Deserialize(json, options); + problemDetails.Should().NotBeNull(); + problemDetails.Title.Should().Be("One or more validation errors occurred."); + problemDetails.Status.Should().Be(400); + } + + + + [Fact] + public void DeserializeServerError() + { + var options = CreateOptions(); + + var json = @"{ + ""type"": ""https://tools.ietf.org/html/rfc9110#section-15.5.1"", + ""title"": ""One or more errors occurred."", + ""status"": 500, + ""traceId"": ""00-894a84e8e9621fb8fcabcb2911e6d51c-5a4777d830f30cff-00"" +}"; + var problemDetails = JsonSerializer.Deserialize(json, options); + problemDetails.Should().NotBeNull(); + problemDetails.Title.Should().Be("One or more errors occurred."); + problemDetails.Status.Should().Be(500); + } + + [Fact] + public void DeserializeServerErrorExtra() + { + var options = CreateOptions(); + + var json = @"{ + ""type"": ""https://tools.ietf.org/html/rfc9110#section-15.5.1"", + ""title"": ""One or more errors occurred."", + ""status"": 500, + ""exception"": ""this is an exception"", + ""traceId"": ""00-894a84e8e9621fb8fcabcb2911e6d51c-5a4777d830f30cff-00"" +}"; + var problemDetails = JsonSerializer.Deserialize(json, options); + problemDetails.Should().NotBeNull(); + problemDetails.Title.Should().Be("One or more errors occurred."); + problemDetails.Status.Should().Be(500); + } + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + options.TypeInfoResolverChain.Add(ProblemDetailsJsonContext.Default); + return options; + } +} diff --git a/test/FluentRest.Tests/QueryBuilderTest.cs b/test/FluentRest.Tests/QueryBuilderTest.cs index 7549ffa..2651117 100644 --- a/test/FluentRest.Tests/QueryBuilderTest.cs +++ b/test/FluentRest.Tests/QueryBuilderTest.cs @@ -38,6 +38,20 @@ public void QueryStringMultipleValue() Assert.Equal("http://test.com/?Test=Test1&Test=Test2", uri.ToString()); } + [Fact] + public void QueryStringMultipleList() + { + var request = new HttpRequestMessage(); + var builder = new QueryBuilder(request); + + builder.BaseUri("http://test.com/"); + builder.QueryString("Test", ["Test1", "Test2"]); + + var uri = request.GetUrlBuilder(); + + Assert.Equal("http://test.com/?Test=Test1&Test=Test2", uri.ToString()); + } + [Fact] public void HeaderSingleValue() {