Skip to content

OpenAPI: Error responses #1460

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 12, 2024
Merged
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

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/JsonApiDotNetCore.OpenApi.Client/ApiResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public static class ApiResponse

try
{
return await operation();
return await operation().ConfigureAwait(false);
}
catch (ApiException exception) when (exception.StatusCode == 204)
{
Expand All @@ -30,7 +30,7 @@ public static async Task TranslateAsync(Func<Task> operation)

try
{
await operation();
await operation().ConfigureAwait(false);
}
catch (ApiException exception) when (exception.StatusCode == 204)
{
Expand Down
16 changes: 12 additions & 4 deletions src/JsonApiDotNetCore.OpenApi.Client/Exceptions/ApiException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@
namespace JsonApiDotNetCore.OpenApi.Client.Exceptions;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class ApiException(
string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception? innerException)
: Exception($"{message}\n\nStatus: {statusCode}\nResponse: \n{response ?? "(null)"}", innerException)
public class ApiException(string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception? innerException)
: Exception($"HTTP {statusCode}: {message}", innerException)
{
public int StatusCode { get; } = statusCode;
public string? Response { get; } = response;
public virtual string? Response { get; } = string.IsNullOrEmpty(response) ? null : response;
public IReadOnlyDictionary<string, IEnumerable<string>> Headers { get; } = headers;
}

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class ApiException<TResult>(
string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, TResult result, Exception? innerException)
: ApiException(message, statusCode, response, headers, innerException)
{
public TResult Result { get; } = result;
public override string Response => $"The response body is unavailable. Use the {nameof(Result)} property instead.";
}
22 changes: 11 additions & 11 deletions src/JsonApiDotNetCore.OpenApi/ConfigureMvcOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,28 @@ namespace JsonApiDotNetCore.OpenApi;

internal sealed class ConfigureMvcOptions : IConfigureOptions<MvcOptions>
{
private readonly IControllerResourceMapping _controllerResourceMapping;
private readonly IJsonApiRoutingConvention _jsonApiRoutingConvention;
private readonly OpenApiEndpointConvention _openApiEndpointConvention;
private readonly JsonApiRequestFormatMetadataProvider _jsonApiRequestFormatMetadataProvider;

public ConfigureMvcOptions(IControllerResourceMapping controllerResourceMapping, IJsonApiRoutingConvention jsonApiRoutingConvention)
public ConfigureMvcOptions(IJsonApiRoutingConvention jsonApiRoutingConvention, OpenApiEndpointConvention openApiEndpointConvention,
JsonApiRequestFormatMetadataProvider jsonApiRequestFormatMetadataProvider)
{
ArgumentGuard.NotNull(controllerResourceMapping);
ArgumentGuard.NotNull(jsonApiRoutingConvention);
ArgumentGuard.NotNull(openApiEndpointConvention);
ArgumentGuard.NotNull(jsonApiRequestFormatMetadataProvider);

_controllerResourceMapping = controllerResourceMapping;
_jsonApiRoutingConvention = jsonApiRoutingConvention;
_openApiEndpointConvention = openApiEndpointConvention;
_jsonApiRequestFormatMetadataProvider = jsonApiRequestFormatMetadataProvider;
}

public void Configure(MvcOptions options)
{
AddSwashbuckleCliCompatibility(options);
AddOpenApiEndpointConvention(options);

options.InputFormatters.Add(_jsonApiRequestFormatMetadataProvider);
options.Conventions.Add(_openApiEndpointConvention);
}

private void AddSwashbuckleCliCompatibility(MvcOptions options)
Expand All @@ -33,10 +39,4 @@ private void AddSwashbuckleCliCompatibility(MvcOptions options)
options.Conventions.Insert(0, _jsonApiRoutingConvention);
}
}

private void AddOpenApiEndpointConvention(MvcOptions options)
{
var convention = new OpenApiEndpointConvention(_controllerResourceMapping);
options.Conventions.Add(convention);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,14 @@ internal sealed class JsonApiActionDescriptorCollectionProvider : IActionDescrip

public ActionDescriptorCollection ActionDescriptors => GetActionDescriptors();

public JsonApiActionDescriptorCollectionProvider(IControllerResourceMapping controllerResourceMapping, IActionDescriptorCollectionProvider defaultProvider,
ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
public JsonApiActionDescriptorCollectionProvider(IActionDescriptorCollectionProvider defaultProvider,
JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider)
{
ArgumentGuard.NotNull(controllerResourceMapping);
ArgumentGuard.NotNull(defaultProvider);
ArgumentGuard.NotNull(resourceFieldValidationMetadataProvider);
ArgumentGuard.NotNull(jsonApiEndpointMetadataProvider);

_defaultProvider = defaultProvider;
_jsonApiEndpointMetadataProvider = new JsonApiEndpointMetadataProvider(controllerResourceMapping, resourceFieldValidationMetadataProvider);
_jsonApiEndpointMetadataProvider = jsonApiEndpointMetadataProvider;
}

private ActionDescriptorCollection GetActionDescriptors()
Expand Down Expand Up @@ -167,32 +166,32 @@ private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Typ

private static ActionDescriptor Clone(ActionDescriptor descriptor)
{
var clonedDescriptor = (ActionDescriptor)descriptor.MemberwiseClone();
var clone = (ActionDescriptor)descriptor.MemberwiseClone();

clonedDescriptor.AttributeRouteInfo = (AttributeRouteInfo)descriptor.AttributeRouteInfo!.MemberwiseClone();
clone.AttributeRouteInfo = (AttributeRouteInfo)descriptor.AttributeRouteInfo!.MemberwiseClone();

clonedDescriptor.FilterDescriptors = new List<FilterDescriptor>();
clone.FilterDescriptors = new List<FilterDescriptor>();

foreach (FilterDescriptor filter in descriptor.FilterDescriptors)
{
clonedDescriptor.FilterDescriptors.Add(Clone(filter));
clone.FilterDescriptors.Add(Clone(filter));
}

clonedDescriptor.Parameters = new List<ParameterDescriptor>();
clone.Parameters = new List<ParameterDescriptor>();

foreach (ParameterDescriptor parameter in descriptor.Parameters)
{
clonedDescriptor.Parameters.Add((ParameterDescriptor)parameter.MemberwiseClone());
clone.Parameters.Add((ParameterDescriptor)parameter.MemberwiseClone());
}

return clonedDescriptor;
return clone;
}

private static FilterDescriptor Clone(FilterDescriptor descriptor)
{
var clonedFilter = (IFilterMetadata)descriptor.Filter.MemberwiseClone();
var clone = (IFilterMetadata)descriptor.Filter.MemberwiseClone();

return new FilterDescriptor(clonedFilter, descriptor.Scope)
return new FilterDescriptor(clone, descriptor.Scope)
{
Order = descriptor.Order
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata;
/// </summary>
internal sealed class JsonApiEndpointMetadataProvider
{
private readonly EndpointResolver _endpointResolver;
private readonly IControllerResourceMapping _controllerResourceMapping;
private readonly EndpointResolver _endpointResolver = new();
private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory;

public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerResourceMapping,
ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
public JsonApiEndpointMetadataProvider(EndpointResolver endpointResolver, IControllerResourceMapping controllerResourceMapping,
NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory)
{
ArgumentGuard.NotNull(endpointResolver);
ArgumentGuard.NotNull(controllerResourceMapping);
ArgumentGuard.NotNull(resourceFieldValidationMetadataProvider);
ArgumentGuard.NotNull(nonPrimaryDocumentTypeFactory);

_nonPrimaryDocumentTypeFactory = new NonPrimaryDocumentTypeFactory(resourceFieldValidationMetadataProvider);
_endpointResolver = endpointResolver;
_controllerResourceMapping = controllerResourceMapping;
_nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory;
}

public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata;

internal sealed class RelationshipTypeFactory
{
private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider;
private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory;
private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider;

public RelationshipTypeFactory(ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
public RelationshipTypeFactory(NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory,
ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
{
ArgumentGuard.NotNull(nonPrimaryDocumentTypeFactory);
ArgumentGuard.NotNull(resourceFieldValidationMetadataProvider);

_nonPrimaryDocumentTypeFactory = new NonPrimaryDocumentTypeFactory(resourceFieldValidationMetadataProvider);
_nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory;
_resourceFieldValidationMetadataProvider = resourceFieldValidationMetadataProvider;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using JetBrains.Annotations;
using JsonApiDotNetCore.Serialization.Objects;

namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
internal sealed class ErrorResponseDocument
{
[Required]
[JsonPropertyName("errors")]
public IList<ErrorObject> Errors { get; set; } = new List<ErrorObject>();
}
117 changes: 79 additions & 38 deletions src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using System.Net;
using System.Reflection;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.OpenApi.JsonApiMetadata;
using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents;
using JsonApiDotNetCore.Resources.Annotations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;

Expand All @@ -16,13 +17,18 @@ namespace JsonApiDotNetCore.OpenApi;
internal sealed class OpenApiEndpointConvention : IActionModelConvention
{
private readonly IControllerResourceMapping _controllerResourceMapping;
private readonly EndpointResolver _endpointResolver = new();
private readonly EndpointResolver _endpointResolver;
private readonly IJsonApiOptions _options;

public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping)
public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping, EndpointResolver endpointResolver, IJsonApiOptions options)
{
ArgumentGuard.NotNull(controllerResourceMapping);
ArgumentGuard.NotNull(endpointResolver);
ArgumentGuard.NotNull(options);

_controllerResourceMapping = controllerResourceMapping;
_endpointResolver = endpointResolver;
_options = options;
}

public void Apply(ActionModel action)
Expand All @@ -39,25 +45,25 @@ public void Apply(ActionModel action)
return;
}

if (ShouldSuppressEndpoint(endpoint.Value, action.Controller.ControllerType))
ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(action.Controller.ControllerType);

if (resourceType == null)
{
throw new UnreachableCodeException();
}

if (ShouldSuppressEndpoint(endpoint.Value, resourceType))
{
action.ApiExplorer.IsVisible = false;
return;
}

SetResponseMetadata(action, endpoint.Value);
SetResponseMetadata(action, endpoint.Value, resourceType);
SetRequestMetadata(action, endpoint.Value);
}

private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, Type controllerType)
private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, ResourceType resourceType)
{
ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(controllerType);

if (resourceType == null)
{
throw new UnreachableCodeException();
}

if (!IsEndpointAvailable(endpoint, resourceType))
{
return true;
Expand Down Expand Up @@ -121,49 +127,84 @@ private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint)
JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship;
}

private static void SetResponseMetadata(ActionModel action, JsonApiEndpoint endpoint)
private void SetResponseMetadata(ActionModel action, JsonApiEndpoint endpoint, ResourceType resourceType)
{
foreach (int statusCode in GetStatusCodesForEndpoint(endpoint))
action.Filters.Add(new ProducesAttribute(HeaderConstants.MediaType));

foreach (HttpStatusCode statusCode in GetSuccessStatusCodesForEndpoint(endpoint))
{
action.Filters.Add(new ProducesResponseTypeAttribute(statusCode));
// The return type is set later by JsonApiActionDescriptorCollectionProvider.
action.Filters.Add(new ProducesResponseTypeAttribute((int)statusCode));
}

switch (endpoint)
{
case JsonApiEndpoint.GetCollection when statusCode == StatusCodes.Status200OK:
case JsonApiEndpoint.Post when statusCode == StatusCodes.Status201Created:
case JsonApiEndpoint.Patch when statusCode == StatusCodes.Status200OK:
case JsonApiEndpoint.GetSingle when statusCode == StatusCodes.Status200OK:
case JsonApiEndpoint.GetSecondary when statusCode == StatusCodes.Status200OK:
case JsonApiEndpoint.GetRelationship when statusCode == StatusCodes.Status200OK:
{
action.Filters.Add(new ProducesAttribute(HeaderConstants.MediaType));
break;
}
}
foreach (HttpStatusCode statusCode in GetErrorStatusCodesForEndpoint(endpoint, resourceType))
{
action.Filters.Add(new ProducesResponseTypeAttribute(typeof(ErrorResponseDocument), (int)statusCode));
}
}

private static IEnumerable<int> GetStatusCodesForEndpoint(JsonApiEndpoint endpoint)
private static IEnumerable<HttpStatusCode> GetSuccessStatusCodesForEndpoint(JsonApiEndpoint endpoint)
{
return endpoint switch
{
JsonApiEndpoint.GetCollection or JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship =>
JsonApiEndpoint.GetCollection or JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship
=> [HttpStatusCode.OK],
JsonApiEndpoint.Post =>
[
HttpStatusCode.Created,
HttpStatusCode.NoContent
],
JsonApiEndpoint.Patch =>
[
HttpStatusCode.OK,
HttpStatusCode.NoContent
],
JsonApiEndpoint.Delete or JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship =>
[
HttpStatusCode.NoContent
],
_ => throw new UnreachableCodeException()
};
}

private IEnumerable<HttpStatusCode> GetErrorStatusCodesForEndpoint(JsonApiEndpoint endpoint, ResourceType resourceType)
{
ClientIdGenerationMode clientIdGeneration = resourceType.ClientIdGeneration ?? _options.ClientIdGeneration;

return endpoint switch
{
JsonApiEndpoint.GetCollection => [HttpStatusCode.BadRequest],
JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship =>
[
HttpStatusCode.BadRequest,
HttpStatusCode.NotFound
],
JsonApiEndpoint.Post when clientIdGeneration == ClientIdGenerationMode.Forbidden =>
[
StatusCodes.Status200OK
HttpStatusCode.BadRequest,
HttpStatusCode.Forbidden,
HttpStatusCode.Conflict,
HttpStatusCode.UnprocessableEntity
],
JsonApiEndpoint.Post =>
[
StatusCodes.Status201Created,
StatusCodes.Status204NoContent
HttpStatusCode.BadRequest,
HttpStatusCode.Conflict,
HttpStatusCode.UnprocessableEntity
],
JsonApiEndpoint.Patch =>
[
StatusCodes.Status200OK,
StatusCodes.Status204NoContent
HttpStatusCode.BadRequest,
HttpStatusCode.NotFound,
HttpStatusCode.Conflict,
HttpStatusCode.UnprocessableEntity
],
JsonApiEndpoint.Delete or JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship => new[]
JsonApiEndpoint.Delete => [HttpStatusCode.NotFound],
JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship => new[]
{
StatusCodes.Status204NoContent
HttpStatusCode.BadRequest,
HttpStatusCode.NotFound,
HttpStatusCode.Conflict
},
_ => throw new UnreachableCodeException()
};
Expand Down
Loading