Skip to content

Commit 2af36db

Browse files
authored
Merge pull request #1460 from json-api-dotnet/openapi-error-responses
OpenAPI: Error responses
2 parents f7f8f8b + 0cd9754 commit 2af36db

File tree

31 files changed

+8186
-1057
lines changed

31 files changed

+8186
-1057
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/ApiResponse.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public static class ApiResponse
1515

1616
try
1717
{
18-
return await operation();
18+
return await operation().ConfigureAwait(false);
1919
}
2020
catch (ApiException exception) when (exception.StatusCode == 204)
2121
{
@@ -30,7 +30,7 @@ public static async Task TranslateAsync(Func<Task> operation)
3030

3131
try
3232
{
33-
await operation();
33+
await operation().ConfigureAwait(false);
3434
}
3535
catch (ApiException exception) when (exception.StatusCode == 204)
3636
{

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+
}

src/JsonApiDotNetCore.OpenApi/ConfigureMvcOptions.cs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,28 @@ namespace JsonApiDotNetCore.OpenApi;
77

88
internal sealed class ConfigureMvcOptions : IConfigureOptions<MvcOptions>
99
{
10-
private readonly IControllerResourceMapping _controllerResourceMapping;
1110
private readonly IJsonApiRoutingConvention _jsonApiRoutingConvention;
11+
private readonly OpenApiEndpointConvention _openApiEndpointConvention;
12+
private readonly JsonApiRequestFormatMetadataProvider _jsonApiRequestFormatMetadataProvider;
1213

13-
public ConfigureMvcOptions(IControllerResourceMapping controllerResourceMapping, IJsonApiRoutingConvention jsonApiRoutingConvention)
14+
public ConfigureMvcOptions(IJsonApiRoutingConvention jsonApiRoutingConvention, OpenApiEndpointConvention openApiEndpointConvention,
15+
JsonApiRequestFormatMetadataProvider jsonApiRequestFormatMetadataProvider)
1416
{
15-
ArgumentGuard.NotNull(controllerResourceMapping);
1617
ArgumentGuard.NotNull(jsonApiRoutingConvention);
18+
ArgumentGuard.NotNull(openApiEndpointConvention);
19+
ArgumentGuard.NotNull(jsonApiRequestFormatMetadataProvider);
1720

18-
_controllerResourceMapping = controllerResourceMapping;
1921
_jsonApiRoutingConvention = jsonApiRoutingConvention;
22+
_openApiEndpointConvention = openApiEndpointConvention;
23+
_jsonApiRequestFormatMetadataProvider = jsonApiRequestFormatMetadataProvider;
2024
}
2125

2226
public void Configure(MvcOptions options)
2327
{
2428
AddSwashbuckleCliCompatibility(options);
25-
AddOpenApiEndpointConvention(options);
29+
30+
options.InputFormatters.Add(_jsonApiRequestFormatMetadataProvider);
31+
options.Conventions.Add(_openApiEndpointConvention);
2632
}
2733

2834
private void AddSwashbuckleCliCompatibility(MvcOptions options)
@@ -33,10 +39,4 @@ private void AddSwashbuckleCliCompatibility(MvcOptions options)
3339
options.Conventions.Insert(0, _jsonApiRoutingConvention);
3440
}
3541
}
36-
37-
private void AddOpenApiEndpointConvention(MvcOptions options)
38-
{
39-
var convention = new OpenApiEndpointConvention(_controllerResourceMapping);
40-
options.Conventions.Add(convention);
41-
}
4242
}

src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,14 @@ internal sealed class JsonApiActionDescriptorCollectionProvider : IActionDescrip
2222

2323
public ActionDescriptorCollection ActionDescriptors => GetActionDescriptors();
2424

25-
public JsonApiActionDescriptorCollectionProvider(IControllerResourceMapping controllerResourceMapping, IActionDescriptorCollectionProvider defaultProvider,
26-
ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
25+
public JsonApiActionDescriptorCollectionProvider(IActionDescriptorCollectionProvider defaultProvider,
26+
JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider)
2727
{
28-
ArgumentGuard.NotNull(controllerResourceMapping);
2928
ArgumentGuard.NotNull(defaultProvider);
30-
ArgumentGuard.NotNull(resourceFieldValidationMetadataProvider);
29+
ArgumentGuard.NotNull(jsonApiEndpointMetadataProvider);
3130

3231
_defaultProvider = defaultProvider;
33-
_jsonApiEndpointMetadataProvider = new JsonApiEndpointMetadataProvider(controllerResourceMapping, resourceFieldValidationMetadataProvider);
32+
_jsonApiEndpointMetadataProvider = jsonApiEndpointMetadataProvider;
3433
}
3534

3635
private ActionDescriptorCollection GetActionDescriptors()
@@ -167,32 +166,32 @@ private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Typ
167166

168167
private static ActionDescriptor Clone(ActionDescriptor descriptor)
169168
{
170-
var clonedDescriptor = (ActionDescriptor)descriptor.MemberwiseClone();
169+
var clone = (ActionDescriptor)descriptor.MemberwiseClone();
171170

172-
clonedDescriptor.AttributeRouteInfo = (AttributeRouteInfo)descriptor.AttributeRouteInfo!.MemberwiseClone();
171+
clone.AttributeRouteInfo = (AttributeRouteInfo)descriptor.AttributeRouteInfo!.MemberwiseClone();
173172

174-
clonedDescriptor.FilterDescriptors = new List<FilterDescriptor>();
173+
clone.FilterDescriptors = new List<FilterDescriptor>();
175174

176175
foreach (FilterDescriptor filter in descriptor.FilterDescriptors)
177176
{
178-
clonedDescriptor.FilterDescriptors.Add(Clone(filter));
177+
clone.FilterDescriptors.Add(Clone(filter));
179178
}
180179

181-
clonedDescriptor.Parameters = new List<ParameterDescriptor>();
180+
clone.Parameters = new List<ParameterDescriptor>();
182181

183182
foreach (ParameterDescriptor parameter in descriptor.Parameters)
184183
{
185-
clonedDescriptor.Parameters.Add((ParameterDescriptor)parameter.MemberwiseClone());
184+
clone.Parameters.Add((ParameterDescriptor)parameter.MemberwiseClone());
186185
}
187186

188-
return clonedDescriptor;
187+
return clone;
189188
}
190189

191190
private static FilterDescriptor Clone(FilterDescriptor descriptor)
192191
{
193-
var clonedFilter = (IFilterMetadata)descriptor.Filter.MemberwiseClone();
192+
var clone = (IFilterMetadata)descriptor.Filter.MemberwiseClone();
194193

195-
return new FilterDescriptor(clonedFilter, descriptor.Scope)
194+
return new FilterDescriptor(clone, descriptor.Scope)
196195
{
197196
Order = descriptor.Order
198197
};

src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,20 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata;
1212
/// </summary>
1313
internal sealed class JsonApiEndpointMetadataProvider
1414
{
15+
private readonly EndpointResolver _endpointResolver;
1516
private readonly IControllerResourceMapping _controllerResourceMapping;
16-
private readonly EndpointResolver _endpointResolver = new();
1717
private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory;
1818

19-
public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerResourceMapping,
20-
ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
19+
public JsonApiEndpointMetadataProvider(EndpointResolver endpointResolver, IControllerResourceMapping controllerResourceMapping,
20+
NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory)
2121
{
22+
ArgumentGuard.NotNull(endpointResolver);
2223
ArgumentGuard.NotNull(controllerResourceMapping);
23-
ArgumentGuard.NotNull(resourceFieldValidationMetadataProvider);
24+
ArgumentGuard.NotNull(nonPrimaryDocumentTypeFactory);
2425

25-
_nonPrimaryDocumentTypeFactory = new NonPrimaryDocumentTypeFactory(resourceFieldValidationMetadataProvider);
26+
_endpointResolver = endpointResolver;
2627
_controllerResourceMapping = controllerResourceMapping;
28+
_nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory;
2729
}
2830

2931
public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction)

src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipTypeFactory.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata;
55

66
internal sealed class RelationshipTypeFactory
77
{
8-
private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider;
98
private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory;
9+
private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider;
1010

11-
public RelationshipTypeFactory(ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
11+
public RelationshipTypeFactory(NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory,
12+
ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
1213
{
14+
ArgumentGuard.NotNull(nonPrimaryDocumentTypeFactory);
1315
ArgumentGuard.NotNull(resourceFieldValidationMetadataProvider);
1416

15-
_nonPrimaryDocumentTypeFactory = new NonPrimaryDocumentTypeFactory(resourceFieldValidationMetadataProvider);
17+
_nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory;
1618
_resourceFieldValidationMetadataProvider = resourceFieldValidationMetadataProvider;
1719
}
1820

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: 79 additions & 38 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

@@ -16,13 +17,18 @@ namespace JsonApiDotNetCore.OpenApi;
1617
internal sealed class OpenApiEndpointConvention : IActionModelConvention
1718
{
1819
private readonly IControllerResourceMapping _controllerResourceMapping;
19-
private readonly EndpointResolver _endpointResolver = new();
20+
private readonly EndpointResolver _endpointResolver;
21+
private readonly IJsonApiOptions _options;
2022

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

2529
_controllerResourceMapping = controllerResourceMapping;
30+
_endpointResolver = endpointResolver;
31+
_options = options;
2632
}
2733

2834
public void Apply(ActionModel action)
@@ -39,25 +45,25 @@ public void Apply(ActionModel action)
3945
return;
4046
}
4147

42-
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))
4356
{
4457
action.ApiExplorer.IsVisible = false;
4558
return;
4659
}
4760

48-
SetResponseMetadata(action, endpoint.Value);
61+
SetResponseMetadata(action, endpoint.Value, resourceType);
4962
SetRequestMetadata(action, endpoint.Value);
5063
}
5164

52-
private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, Type controllerType)
65+
private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, ResourceType resourceType)
5366
{
54-
ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(controllerType);
55-
56-
if (resourceType == null)
57-
{
58-
throw new UnreachableCodeException();
59-
}
60-
6167
if (!IsEndpointAvailable(endpoint, resourceType))
6268
{
6369
return true;
@@ -121,49 +127,84 @@ private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint)
121127
JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship;
122128
}
123129

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

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

146-
private static IEnumerable<int> GetStatusCodesForEndpoint(JsonApiEndpoint endpoint)
146+
private static IEnumerable<HttpStatusCode> GetSuccessStatusCodesForEndpoint(JsonApiEndpoint endpoint)
147147
{
148148
return endpoint switch
149149
{
150-
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 =>
151183
[
152-
StatusCodes.Status200OK
184+
HttpStatusCode.BadRequest,
185+
HttpStatusCode.Forbidden,
186+
HttpStatusCode.Conflict,
187+
HttpStatusCode.UnprocessableEntity
153188
],
154189
JsonApiEndpoint.Post =>
155190
[
156-
StatusCodes.Status201Created,
157-
StatusCodes.Status204NoContent
191+
HttpStatusCode.BadRequest,
192+
HttpStatusCode.Conflict,
193+
HttpStatusCode.UnprocessableEntity
158194
],
159195
JsonApiEndpoint.Patch =>
160196
[
161-
StatusCodes.Status200OK,
162-
StatusCodes.Status204NoContent
197+
HttpStatusCode.BadRequest,
198+
HttpStatusCode.NotFound,
199+
HttpStatusCode.Conflict,
200+
HttpStatusCode.UnprocessableEntity
163201
],
164-
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[]
165204
{
166-
StatusCodes.Status204NoContent
205+
HttpStatusCode.BadRequest,
206+
HttpStatusCode.NotFound,
207+
HttpStatusCode.Conflict
167208
},
168209
_ => throw new UnreachableCodeException()
169210
};

0 commit comments

Comments
 (0)