Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Refit.NativeAotSmoke/INativeAotApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ Task<ServiceStatus> SearchAsync(
[Get("/unregistered")]
Task<SmokeUnregistered> GetUnregisteredAsync();

/// <summary>Gets a problem details reply, whose type the JSON context does not describe.</summary>
/// <returns>A task that faults with a <see cref="ValidationApiException"/>.</returns>
[Get("/problem")]
Task GetProblemAsync();

/// <summary>An intentionally reflection-backed method shape proving the generated fallback builds cleanly
/// under full trimming with IL2026/IL3050 promoted to errors. It is never invoked.</summary>
/// <param name="filters">A query object whose shape is only known at runtime, so the generator falls back.</param>
Expand Down
14 changes: 14 additions & 0 deletions src/Refit.NativeAotSmoke/NativeAotSmokeHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ protected override Task<HttpResponseMessage> SendAsync(
"/uploads" => HandleUploadAsync(request, cancellationToken),
"/search" => Task.FromResult(HandleSearch(request)),
"/status" or "/unregistered" => Task.FromResult(Json("""{"name":"native-aot"}""")),
"/problem" => Task.FromResult(Problem(request)),
_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)),
};

Expand All @@ -48,6 +49,19 @@ protected override Task<HttpResponseMessage> SendAsync(
private static HttpResponseMessage Json(string content) =>
new(HttpStatusCode.OK) { Content = new StringContent(content, Encoding.UTF8, "application/json") };

/// <summary>Builds a bad request response with a problem details body.</summary>
/// <param name="request">The request being answered.</param>
/// <returns>The constructed problem response message.</returns>
private static HttpResponseMessage Problem(HttpRequestMessage request) =>
new(HttpStatusCode.BadRequest)
{
RequestMessage = request,
Content = new StringContent(
"""{"title":"Invalid","status":400,"errors":{"name":["Name is required."]},"traceId":"smoke-trace"}""",
Encoding.UTF8,
"application/problem+json"),
};

/// <summary>Serves <c>/todos</c>, recording whether the expected title reached this handler.</summary>
/// <param name="request">The request to serve.</param>
/// <param name="cancellationToken">A token that cancels reading the body.</param>
Expand Down
14 changes: 14 additions & 0 deletions src/Refit.NativeAotSmoke/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@
// Expected: the generated JSON context is the only metadata source.
}

// The context does not describe ProblemDetails either, but a problem reply must still become a validation exception.
try
{
await api.GetProblemAsync().ConfigureAwait(false);
throw new InvalidOperationException("The AOT problem reply did not fault.");
}
catch (ValidationApiException ex)
{
if (ex.Content?.Errors["name"][0] != "Name is required." || ex.Content.Extensions["traceId"] as string != "smoke-trace")
{
throw new InvalidOperationException("The AOT problem details were not read correctly.");
}
}

// A missed generated-client lookup forces the interface assembly's module initializer and looks again, which is what
// rescues runtimes that have not run it yet. Prove that forcing path is reachable under Native AOT.
string? missingClientMessage = null;
Expand Down
30 changes: 24 additions & 6 deletions src/Refit/ValidationApiException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,21 @@ public ValidationApiException(string message, Exception innerException)
/// <param name="apiException">The originating API exception to wrap.</param>
internal ValidationApiException(ApiException apiException)
: base(
apiException.Message,
apiException.RequestMessage,
apiException.HttpMethod,
apiException.Content,
apiException.StatusCode,
apiException.ReasonPhrase,
apiException.Headers,
apiException.RefitSettings) =>
// Carry over the content headers from the originating ApiException so callers
// can inspect them on the validation exception too.
apiException.RefitSettings,
apiException.InnerException)
{
// Carry over the content headers and captured request content from the originating
// ApiException so callers can inspect them on the validation exception too.
ContentHeaders = apiException.ContentHeaders;
RequestContent = apiException.RequestContent;
}

/// <summary>Gets the problem details of the RFC 7807 validation exception.</summary>
public new ProblemDetails? Content { get; private set; }
Expand All @@ -92,6 +97,9 @@ public static ValidationApiException Create(ApiException exception)
/// </summary>
/// <param name="exception">An instance of an ApiException to use to build a ValidationException.</param>
/// <returns>ValidationApiException.</returns>
[DynamicDependency(
DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties,
typeof(ProblemDetails))]
internal static async Task<ValidationApiException> CreateAsync(ApiException exception)
{
var ex = CreateCore(exception);
Expand All @@ -103,9 +111,19 @@ internal static async Task<ValidationApiException> CreateAsync(ApiException exce
exception.Content!,
Encoding.UTF8,
"application/problem+json");
ex.Content = await exception
.RefitSettings.ContentSerializer.FromHttpContentAsync<ProblemDetails>(content)
.ConfigureAwait(false);
try
{
ex.Content = await exception
.RefitSettings.ContentSerializer.FromHttpContentAsync<ProblemDetails>(content)
.ConfigureAwait(false);
}
catch (Exception serializerException) when (serializerException is NotSupportedException or InvalidOperationException)
{
// Source-generated contexts, trimming and Native AOT can leave the serializer without
// usable ProblemDetails metadata, so fall back to the reader that needs none. A JsonException
// means the serializer rejected the body itself, which stays a plain ApiException.
ex.Content = DeserializeProblemDetails(exception.Content!);
}

return ex;
}
Expand Down
13 changes: 6 additions & 7 deletions src/examples/Documentation/Errors/Errors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ private static void ReadSynchronously(ApiException error, Failure? expected)
SampleCheck.Equal(true, read);
}

/// <summary>Handles a problem response through the public manual parser.</summary>
/// <summary>Handles a problem response, which Refit raises as a validation exception.</summary>
/// <param name="api">The generated client for the problem route.</param>
/// <returns>Completion after the problem response is checked.</returns>
/// <exception cref="InvalidOperationException">The local problem request unexpectedly succeeds.</exception>
Expand All @@ -107,21 +107,20 @@ private static async Task ReadProblemAsync(IErrorsApi api)
_ = await api.GetProblemAsync(CancellationToken.None);
throw new InvalidOperationException("The problem reply should throw.");
}
catch (ApiException error)
catch (ValidationApiException error)
{
ReadProblem(error);
}
}

/// <summary>Checks validation entries and extension data without JSON reflection.</summary>
/// <param name="error">The HTTP exception retaining the problem body.</param>
private static void ReadProblem(ApiException error)
/// <param name="error">The validation exception retaining the problem body.</param>
private static void ReadProblem(ValidationApiException error)
{
ValidationApiException validation = error as ValidationApiException ?? ValidationApiException.Create(error);
ProblemDetails? problem = validation.Content;
ProblemDetails? problem = error.Content;
Console.WriteLine(problem?.Title); // Invalid name
Console.WriteLine(problem?.Errors["name"][0]); // Name is required.
Console.WriteLine(error.Content); // Raw JSON
Console.WriteLine(((ApiException)error).Content); // Raw JSON

SampleCheck.Equal((int)HttpStatusCode.BadRequest, problem?.Status);
SampleCheck.Equal("sample", problem?.Extensions["trace"].ToString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ private static async Task ShowRejectedOrderAsync(IOrdersApi api)
_ = await api.PlaceOrderAsync(new("Ada", []), CancellationToken.None);
throw new InvalidOperationException("The shop should reject an order with no lines.");
}
catch (ApiException error)
catch (ValidationApiException error)
{
ProblemDetails? problem = ValidationApiException.Create(error).Content;
ProblemDetails? problem = error.Content;
Console.WriteLine(problem?.Errors["lines"][0]); // An order needs at least one line.
SampleCheck.Equal("An order needs at least one line.", problem?.Errors["lines"][0]);
}
Expand Down
128 changes: 128 additions & 0 deletions src/tests/Refit.Tests/ApiExceptionTests.ProblemDetailsFallback.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.Diagnostics.CodeAnalysis;

namespace Refit.Tests;

/// <summary>Tests for problem details that the configured serializer cannot read.</summary>
public sealed partial class ApiExceptionTests
{
/// <summary>A problem details body with a validation error and an extension member.</summary>
private const string ProblemJson =
"{\"title\":\"Invalid\",\"status\":400,\"errors\":{\"name\":[\"Name is required.\"]},\"traceId\":\"abc\"}";

/// <summary>The validation message asserted by the fallback tests.</summary>
private const string NameRequiredText = "Name is required.";

/// <summary>Verifies a JSON context that does not describe <see cref="ProblemDetails"/> still yields a validation exception.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public Task CreateReadsProblemDetailsWhenContextDoesNotDescribeThem() =>
AssertProblemDetailsRead(RefitSettings.ForJsonContext(InventoryJsonContext.Default));

/// <summary>Verifies a JSON context that describes <see cref="ProblemDetails"/> still yields a validation exception.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public Task CreateReadsProblemDetailsWhenContextDescribesThem() =>
AssertProblemDetailsRead(RefitSettings.ForJsonContext(ProblemDetailsJsonContext.Default));

/// <summary>Verifies a serializer that does not support the type falls back to the metadata-free reader.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public Task CreateReadsProblemDetailsWhenSerializerThrows() =>
AssertProblemDetailsRead(new(new ThrowingSerializer()));

/// <summary>Verifies a body the serializer rejects is not read again by the fallback.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task CreateKeepsPlainExceptionWhenSerializerRejectsTheBody()
{
const string ScalarErrorsJson = "{\"errors\":{\"name\":\"Name is required.\"}}";
using var response = CreateErrorResponse(ScalarErrorsJson, ProblemJsonMediaType);

var exception = await ApiException.Create(response.RequestMessage!, HttpMethod.Get, response, new());

await Assert.That(exception is ValidationApiException).IsFalse();
await Assert.That(exception.Content).IsEqualTo(ScalarErrorsJson);
}

/// <summary>Verifies a body the fallback cannot parse stays a plain exception with the raw content.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task CreateKeepsPlainExceptionWhenFallbackCannotParseTheBody()
{
using var response = CreateErrorResponse("not json", ProblemJsonMediaType);

var exception = await ApiException.Create(
response.RequestMessage!,
HttpMethod.Get,
response,
RefitSettings.ForJsonContext(InventoryJsonContext.Default));

await Assert.That(exception is ValidationApiException).IsFalse();
await Assert.That(exception.Content).IsEqualTo("not json");
}

/// <summary>Verifies the validation exception keeps what the originating exception carried.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task CreateKeepsOriginatingDetailsOnValidationException()
{
using var response = CreateErrorResponse(ProblemJson, ProblemJsonMediaType);
GeneratedRequestRunner.AddRequestProperty(
response.RequestMessage!,
HttpRequestMessageOptions.RequestContent,
RedactedText);
var cause = new InvalidOperationException(MessageText);

var exception = await ApiException.Create(
CustomMessage,
response.RequestMessage!,
HttpMethod.Get,
response,
new(),
cause);

await Assert.That(exception).IsTypeOf<ValidationApiException>();
await Assert.That(exception.Message).IsEqualTo(CustomMessage);
await Assert.That(exception.InnerException).IsSameReferenceAs(cause);
await Assert.That(exception.RequestContent).IsEqualTo(RedactedText);
}

/// <summary>Asserts a problem response becomes a populated validation exception under the given settings.</summary>
/// <param name="settings">The settings used to create the exception.</param>
/// <returns>A task representing the asynchronous assertion.</returns>
private static async Task AssertProblemDetailsRead(RefitSettings settings)
{
using var response = CreateErrorResponse(ProblemJson, ProblemJsonMediaType);

var exception = await ApiException.Create(response.RequestMessage!, HttpMethod.Get, response, settings);

await Assert.That(exception).IsTypeOf<ValidationApiException>();
var problem = ((ValidationApiException)exception).Content!;
await Assert.That(problem.Title).IsEqualTo("Invalid");
await Assert.That(problem.Status).IsEqualTo(ExpectedStatusCode);
await Assert.That(problem.Errors["name"][0]).IsEqualTo(NameRequiredText);
await Assert.That(problem.Extensions["traceId"].ToString()).IsEqualTo("abc");
}

/// <summary>A serializer that cannot read anything.</summary>
[SuppressMessage(
"Design",
"SST2307:Generic method type parameters should be inferable from the parameters",
Justification = "Mirrors the explicit type parameters of the IHttpContentSerializer contract.")]
private sealed class ThrowingSerializer : IHttpContentSerializer
{
/// <inheritdoc />
public HttpContent ToHttpContent<T>(T item) => throw new NotSupportedException();

/// <inheritdoc />
public Task<T?> FromHttpContentAsync<T>(HttpContent content, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();

/// <inheritdoc />
public string? GetFieldNameForProperty(System.Reflection.PropertyInfo propertyInfo) => null;
}
}