Skip to content

Commit 0cd9754

Browse files
committed
Emit error response document in OAS
1 parent b192bfa commit 0cd9754

File tree

22 files changed

+7989
-877
lines changed

22 files changed

+7989
-877
lines changed

src/Examples/JsonApiDotNetCoreExample/GeneratedSwagger/JsonApiDotNetCoreExample.json

Lines changed: 912 additions & 105 deletions
Large diffs are not rendered by default.

src/JsonApiDotNetCore.OpenApi.Client/Exceptions/ApiException.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@
66
namespace JsonApiDotNetCore.OpenApi.Client.Exceptions;
77

88
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
9-
public sealed class ApiException(
10-
string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception? innerException)
11-
: Exception($"{message}\n\nStatus: {statusCode}\nResponse: \n{response ?? "(null)"}", innerException)
9+
public class ApiException(string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception? innerException)
10+
: Exception($"HTTP {statusCode}: {message}", innerException)
1211
{
1312
public int StatusCode { get; } = statusCode;
14-
public string? Response { get; } = response;
13+
public virtual string? Response { get; } = string.IsNullOrEmpty(response) ? null : response;
1514
public IReadOnlyDictionary<string, IEnumerable<string>> Headers { get; } = headers;
1615
}
16+
17+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
18+
public sealed class ApiException<TResult>(
19+
string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, TResult result, Exception? innerException)
20+
: ApiException(message, statusCode, response, headers, innerException)
21+
{
22+
public TResult Result { get; } = result;
23+
public override string Response => $"The response body is unavailable. Use the {nameof(Result)} property instead.";
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using System.Text.Json.Serialization;
3+
using JetBrains.Annotations;
4+
using JsonApiDotNetCore.Serialization.Objects;
5+
6+
namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents;
7+
8+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
9+
internal sealed class ErrorResponseDocument
10+
{
11+
[Required]
12+
[JsonPropertyName("errors")]
13+
public IList<ErrorObject> Errors { get; set; } = new List<ErrorObject>();
14+
}

src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
using System.Net;
12
using System.Reflection;
23
using JsonApiDotNetCore.Configuration;
34
using JsonApiDotNetCore.Controllers;
45
using JsonApiDotNetCore.Middleware;
56
using JsonApiDotNetCore.OpenApi.JsonApiMetadata;
7+
using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents;
68
using JsonApiDotNetCore.Resources.Annotations;
7-
using Microsoft.AspNetCore.Http;
89
using Microsoft.AspNetCore.Mvc;
910
using Microsoft.AspNetCore.Mvc.ApplicationModels;
1011

@@ -17,14 +18,17 @@ internal sealed class OpenApiEndpointConvention : IActionModelConvention
1718
{
1819
private readonly IControllerResourceMapping _controllerResourceMapping;
1920
private readonly EndpointResolver _endpointResolver;
21+
private readonly IJsonApiOptions _options;
2022

21-
public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping, EndpointResolver endpointResolver)
23+
public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping, EndpointResolver endpointResolver, IJsonApiOptions options)
2224
{
2325
ArgumentGuard.NotNull(controllerResourceMapping);
2426
ArgumentGuard.NotNull(endpointResolver);
27+
ArgumentGuard.NotNull(options);
2528

2629
_controllerResourceMapping = controllerResourceMapping;
2730
_endpointResolver = endpointResolver;
31+
_options = options;
2832
}
2933

3034
public void Apply(ActionModel action)
@@ -41,25 +45,25 @@ public void Apply(ActionModel action)
4145
return;
4246
}
4347

44-
if (ShouldSuppressEndpoint(endpoint.Value, action.Controller.ControllerType))
48+
ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(action.Controller.ControllerType);
49+
50+
if (resourceType == null)
51+
{
52+
throw new UnreachableCodeException();
53+
}
54+
55+
if (ShouldSuppressEndpoint(endpoint.Value, resourceType))
4556
{
4657
action.ApiExplorer.IsVisible = false;
4758
return;
4859
}
4960

50-
SetResponseMetadata(action, endpoint.Value);
61+
SetResponseMetadata(action, endpoint.Value, resourceType);
5162
SetRequestMetadata(action, endpoint.Value);
5263
}
5364

54-
private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, Type controllerType)
65+
private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, ResourceType resourceType)
5566
{
56-
ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(controllerType);
57-
58-
if (resourceType == null)
59-
{
60-
throw new UnreachableCodeException();
61-
}
62-
6367
if (!IsEndpointAvailable(endpoint, resourceType))
6468
{
6569
return true;
@@ -123,49 +127,84 @@ private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint)
123127
JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship;
124128
}
125129

126-
private static void SetResponseMetadata(ActionModel action, JsonApiEndpoint endpoint)
130+
private void SetResponseMetadata(ActionModel action, JsonApiEndpoint endpoint, ResourceType resourceType)
127131
{
128-
foreach (int statusCode in GetStatusCodesForEndpoint(endpoint))
132+
action.Filters.Add(new ProducesAttribute(HeaderConstants.MediaType));
133+
134+
foreach (HttpStatusCode statusCode in GetSuccessStatusCodesForEndpoint(endpoint))
129135
{
130-
action.Filters.Add(new ProducesResponseTypeAttribute(statusCode));
136+
// The return type is set later by JsonApiActionDescriptorCollectionProvider.
137+
action.Filters.Add(new ProducesResponseTypeAttribute((int)statusCode));
138+
}
131139

132-
switch (endpoint)
133-
{
134-
case JsonApiEndpoint.GetCollection when statusCode == StatusCodes.Status200OK:
135-
case JsonApiEndpoint.Post when statusCode == StatusCodes.Status201Created:
136-
case JsonApiEndpoint.Patch when statusCode == StatusCodes.Status200OK:
137-
case JsonApiEndpoint.GetSingle when statusCode == StatusCodes.Status200OK:
138-
case JsonApiEndpoint.GetSecondary when statusCode == StatusCodes.Status200OK:
139-
case JsonApiEndpoint.GetRelationship when statusCode == StatusCodes.Status200OK:
140-
{
141-
action.Filters.Add(new ProducesAttribute(HeaderConstants.MediaType));
142-
break;
143-
}
144-
}
140+
foreach (HttpStatusCode statusCode in GetErrorStatusCodesForEndpoint(endpoint, resourceType))
141+
{
142+
action.Filters.Add(new ProducesResponseTypeAttribute(typeof(ErrorResponseDocument), (int)statusCode));
145143
}
146144
}
147145

148-
private static IEnumerable<int> GetStatusCodesForEndpoint(JsonApiEndpoint endpoint)
146+
private static IEnumerable<HttpStatusCode> GetSuccessStatusCodesForEndpoint(JsonApiEndpoint endpoint)
149147
{
150148
return endpoint switch
151149
{
152-
JsonApiEndpoint.GetCollection or JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship =>
150+
JsonApiEndpoint.GetCollection or JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship
151+
=> [HttpStatusCode.OK],
152+
JsonApiEndpoint.Post =>
153+
[
154+
HttpStatusCode.Created,
155+
HttpStatusCode.NoContent
156+
],
157+
JsonApiEndpoint.Patch =>
158+
[
159+
HttpStatusCode.OK,
160+
HttpStatusCode.NoContent
161+
],
162+
JsonApiEndpoint.Delete or JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship =>
163+
[
164+
HttpStatusCode.NoContent
165+
],
166+
_ => throw new UnreachableCodeException()
167+
};
168+
}
169+
170+
private IEnumerable<HttpStatusCode> GetErrorStatusCodesForEndpoint(JsonApiEndpoint endpoint, ResourceType resourceType)
171+
{
172+
ClientIdGenerationMode clientIdGeneration = resourceType.ClientIdGeneration ?? _options.ClientIdGeneration;
173+
174+
return endpoint switch
175+
{
176+
JsonApiEndpoint.GetCollection => [HttpStatusCode.BadRequest],
177+
JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship =>
178+
[
179+
HttpStatusCode.BadRequest,
180+
HttpStatusCode.NotFound
181+
],
182+
JsonApiEndpoint.Post when clientIdGeneration == ClientIdGenerationMode.Forbidden =>
153183
[
154-
StatusCodes.Status200OK
184+
HttpStatusCode.BadRequest,
185+
HttpStatusCode.Forbidden,
186+
HttpStatusCode.Conflict,
187+
HttpStatusCode.UnprocessableEntity
155188
],
156189
JsonApiEndpoint.Post =>
157190
[
158-
StatusCodes.Status201Created,
159-
StatusCodes.Status204NoContent
191+
HttpStatusCode.BadRequest,
192+
HttpStatusCode.Conflict,
193+
HttpStatusCode.UnprocessableEntity
160194
],
161195
JsonApiEndpoint.Patch =>
162196
[
163-
StatusCodes.Status200OK,
164-
StatusCodes.Status204NoContent
197+
HttpStatusCode.BadRequest,
198+
HttpStatusCode.NotFound,
199+
HttpStatusCode.Conflict,
200+
HttpStatusCode.UnprocessableEntity
165201
],
166-
JsonApiEndpoint.Delete or JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship => new[]
202+
JsonApiEndpoint.Delete => [HttpStatusCode.NotFound],
203+
JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship => new[]
167204
{
168-
StatusCodes.Status204NoContent
205+
HttpStatusCode.BadRequest,
206+
HttpStatusCode.NotFound,
207+
HttpStatusCode.Conflict
169208
},
170209
_ => throw new UnreachableCodeException()
171210
};

test/OpenApiClientTests/LegacyClient/ResponseTests.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
using System.Globalization;
22
using System.Net;
33
using FluentAssertions;
4-
using FluentAssertions.Specialized;
54
using JsonApiDotNetCore.OpenApi.Client;
65
using JsonApiDotNetCore.OpenApi.Client.Exceptions;
76
using OpenApiClientTests.LegacyClient.GeneratedCode;
7+
using TestBuildingBlocks;
88
using Xunit;
99

1010
namespace OpenApiClientTests.LegacyClient;
@@ -207,7 +207,7 @@ public async Task Getting_unknown_resource_translates_error_response()
207207
{
208208
"id": "f1a520ac-02a0-466b-94ea-86cbaa86f02f",
209209
"status": "404",
210-
"destination": "The requested resource does not exist.",
210+
"title": "The requested resource does not exist.",
211211
"detail": "Resource of type 'flights' with ID '{{flightId}}' does not exist."
212212
}
213213
]
@@ -221,10 +221,16 @@ public async Task Getting_unknown_resource_translates_error_response()
221221
Func<Task<FlightPrimaryResponseDocument>> action = () => apiClient.GetFlightAsync(flightId, null);
222222

223223
// Assert
224-
ExceptionAssertions<ApiException> assertion = await action.Should().ThrowExactlyAsync<ApiException>();
225-
226-
assertion.Which.StatusCode.Should().Be((int)HttpStatusCode.NotFound);
227-
assertion.Which.Response.Should().Be(responseBody);
224+
ApiException<ErrorResponseDocument> exception = (await action.Should().ThrowExactlyAsync<ApiException<ErrorResponseDocument>>()).Which;
225+
exception.StatusCode.Should().Be((int)HttpStatusCode.NotFound);
226+
exception.Result.Errors.ShouldHaveCount(1);
227+
228+
ErrorObject? error = exception.Result.Errors.ElementAt(0);
229+
error.Id.Should().Be("f1a520ac-02a0-466b-94ea-86cbaa86f02f");
230+
error.Status.Should().Be("404");
231+
error.Title.Should().Be("The requested resource does not exist.");
232+
error.Detail.Should().Be($"Resource of type 'flights' with ID '{flightId}' does not exist.");
233+
error.Source.Should().BeNull();
228234
}
229235

230236
[Fact]

0 commit comments

Comments
 (0)