Skip to content

Commit cd6d5bd

Browse files
authored
Merge pull request #1246 from json-api-dotnet/api-controller-attribute
Better handling of [ApiController] usage
2 parents c835ff2 + 79cfb3d commit cd6d5bd

File tree

5 files changed

+162
-22
lines changed

5 files changed

+162
-22
lines changed

src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs

+19-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Net;
22
using JetBrains.Annotations;
33
using JsonApiDotNetCore.Serialization.Objects;
4+
using Microsoft.AspNetCore.Http;
45
using Microsoft.AspNetCore.Mvc;
56

67
namespace JsonApiDotNetCore.Errors;
@@ -20,20 +21,35 @@ public UnsuccessfulActionResultException(HttpStatusCode status)
2021
}
2122

2223
public UnsuccessfulActionResultException(ProblemDetails problemDetails)
23-
: base(ToError(problemDetails))
24+
: base(ToErrorObjects(problemDetails))
2425
{
2526
}
2627

27-
private static ErrorObject ToError(ProblemDetails problemDetails)
28+
private static IEnumerable<ErrorObject> ToErrorObjects(ProblemDetails problemDetails)
2829
{
2930
ArgumentGuard.NotNull(problemDetails);
3031

3132
HttpStatusCode status = problemDetails.Status != null ? (HttpStatusCode)problemDetails.Status.Value : HttpStatusCode.InternalServerError;
3233

34+
if (problemDetails is HttpValidationProblemDetails validationProblemDetails && validationProblemDetails.Errors.Any())
35+
{
36+
foreach (string errorMessage in validationProblemDetails.Errors.SelectMany(pair => pair.Value))
37+
{
38+
yield return ToErrorObject(status, validationProblemDetails, errorMessage);
39+
}
40+
}
41+
else
42+
{
43+
yield return ToErrorObject(status, problemDetails, problemDetails.Detail);
44+
}
45+
}
46+
47+
private static ErrorObject ToErrorObject(HttpStatusCode status, ProblemDetails problemDetails, string? detail)
48+
{
3349
var error = new ErrorObject(status)
3450
{
3551
Title = problemDetails.Title,
36-
Detail = problemDetails.Detail
52+
Detail = detail
3753
};
3854

3955
if (!string.IsNullOrWhiteSpace(problemDetails.Instance))

src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs

+47-19
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using JsonApiDotNetCore.Resources;
88
using Microsoft.AspNetCore.Mvc;
99
using Microsoft.AspNetCore.Mvc.ApplicationModels;
10+
using Microsoft.Extensions.Logging;
1011

1112
namespace JsonApiDotNetCore.Middleware;
1213

@@ -30,17 +31,20 @@ public sealed class JsonApiRoutingConvention : IJsonApiRoutingConvention
3031
{
3132
private readonly IJsonApiOptions _options;
3233
private readonly IResourceGraph _resourceGraph;
34+
private readonly ILogger<JsonApiRoutingConvention> _logger;
3335
private readonly Dictionary<string, string> _registeredControllerNameByTemplate = new();
3436
private readonly Dictionary<Type, ResourceType> _resourceTypePerControllerTypeMap = new();
3537
private readonly Dictionary<ResourceType, ControllerModel> _controllerPerResourceTypeMap = new();
3638

37-
public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph)
39+
public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, ILogger<JsonApiRoutingConvention> logger)
3840
{
3941
ArgumentGuard.NotNull(options);
4042
ArgumentGuard.NotNull(resourceGraph);
43+
ArgumentGuard.NotNull(logger);
4144

4245
_options = options;
4346
_resourceGraph = resourceGraph;
47+
_logger = logger;
4448
}
4549

4650
/// <inheritdoc />
@@ -64,36 +68,51 @@ public void Apply(ApplicationModel application)
6468

6569
foreach (ControllerModel controller in application.Controllers)
6670
{
67-
bool isOperationsController = IsOperationsController(controller.ControllerType);
71+
if (!IsJsonApiController(controller))
72+
{
73+
continue;
74+
}
75+
76+
if (HasApiControllerAttribute(controller))
77+
{
78+
// Although recommended by Microsoft for hard-written controllers, the opinionated behavior of [ApiController] violates the JSON:API specification.
79+
// See https://learn.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-7.0#apicontroller-attribute for its effects.
80+
// JsonApiDotNetCore already handles all of these concerns, but in a JSON:API-compliant way. So the attribute doesn't do any good.
6881

69-
if (!isOperationsController)
82+
// While we try our best when [ApiController] is used, we can't completely avoid a degraded experience. ModelState validation errors are turned into
83+
// ProblemDetails, where the origin of the error gets lost. As a result, we can't populate the source pointer in JSON:API error responses.
84+
// For backwards-compatibility, we log a warning instead of throwing. But we can't think of any use cases where having [ApiController] makes sense.
85+
86+
_logger.LogWarning(
87+
$"Found JSON:API controller '{controller.ControllerType}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance.");
88+
}
89+
90+
if (!IsOperationsController(controller.ControllerType))
7091
{
7192
Type? resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType);
7293

7394
if (resourceClrType != null)
7495
{
7596
ResourceType? resourceType = _resourceGraph.FindResourceType(resourceClrType);
7697

77-
if (resourceType != null)
78-
{
79-
if (_controllerPerResourceTypeMap.ContainsKey(resourceType))
80-
{
81-
throw new InvalidConfigurationException(
82-
$"Multiple controllers found for resource type '{resourceType}': '{_controllerPerResourceTypeMap[resourceType].ControllerType}' and '{controller.ControllerType}'.");
83-
}
84-
85-
_resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType);
86-
_controllerPerResourceTypeMap.Add(resourceType, controller);
87-
}
88-
else
98+
if (resourceType == null)
8999
{
90100
throw new InvalidConfigurationException($"Controller '{controller.ControllerType}' depends on " +
91101
$"resource type '{resourceClrType}', which does not exist in the resource graph.");
92102
}
103+
104+
if (_controllerPerResourceTypeMap.ContainsKey(resourceType))
105+
{
106+
throw new InvalidConfigurationException(
107+
$"Multiple controllers found for resource type '{resourceType}': '{_controllerPerResourceTypeMap[resourceType].ControllerType}' and '{controller.ControllerType}'.");
108+
}
109+
110+
_resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType);
111+
_controllerPerResourceTypeMap.Add(resourceType, controller);
93112
}
94113
}
95114

96-
if (!IsRoutingConventionEnabled(controller))
115+
if (IsRoutingConventionDisabled(controller))
97116
{
98117
continue;
99118
}
@@ -115,10 +134,19 @@ public void Apply(ApplicationModel application)
115134
}
116135
}
117136

118-
private bool IsRoutingConventionEnabled(ControllerModel controller)
137+
private static bool IsJsonApiController(ControllerModel controller)
138+
{
139+
return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController));
140+
}
141+
142+
private static bool HasApiControllerAttribute(ControllerModel controller)
143+
{
144+
return controller.ControllerType.GetCustomAttribute<ApiControllerAttribute>() != null;
145+
}
146+
147+
private static bool IsRoutingConventionDisabled(ControllerModel controller)
119148
{
120-
return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController)) &&
121-
controller.ControllerType.GetCustomAttribute<DisableRoutingConventionAttribute>(true) == null;
149+
return controller.ControllerType.GetCustomAttribute<DisableRoutingConventionAttribute>(true) != null;
122150
}
123151

124152
/// <summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using FluentAssertions;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging;
4+
using TestBuildingBlocks;
5+
using Xunit;
6+
7+
namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes;
8+
9+
public sealed class ApiControllerAttributeLogTests : IntegrationTestContext<TestableStartup<CustomRouteDbContext>, CustomRouteDbContext>
10+
{
11+
private readonly FakeLoggerFactory _loggerFactory;
12+
13+
public ApiControllerAttributeLogTests()
14+
{
15+
UseController<CiviliansController>();
16+
17+
_loggerFactory = new FakeLoggerFactory(LogLevel.Warning);
18+
19+
ConfigureLogging(options =>
20+
{
21+
options.ClearProviders();
22+
options.AddProvider(_loggerFactory);
23+
});
24+
25+
ConfigureServicesBeforeStartup(services =>
26+
{
27+
services.AddSingleton(_loggerFactory);
28+
});
29+
}
30+
31+
[Fact]
32+
public void Logs_warning_at_startup_when_ApiControllerAttribute_found()
33+
{
34+
// Arrange
35+
_loggerFactory.Logger.Clear();
36+
37+
// Act
38+
_ = Factory;
39+
40+
// Assert
41+
_loggerFactory.Logger.Messages.ShouldHaveCount(1);
42+
_loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning);
43+
44+
_loggerFactory.Logger.Messages.Single().Text.Should().Be(
45+
$"Found JSON:API controller '{typeof(CiviliansController)}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance.");
46+
}
47+
}

test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs

+44
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,48 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with
3535
error.Links.ShouldNotBeNull();
3636
error.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4");
3737
}
38+
39+
[Fact]
40+
public async Task ProblemDetails_from_invalid_ModelState_is_translated_into_error_response()
41+
{
42+
// Arrange
43+
var requestBody = new
44+
{
45+
data = new
46+
{
47+
type = "civilians",
48+
attributes = new
49+
{
50+
name = (string?)null,
51+
yearOfBirth = 1850
52+
}
53+
}
54+
};
55+
56+
const string route = "/world-civilians";
57+
58+
// Act
59+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody);
60+
61+
// Assert
62+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
63+
64+
responseDocument.Errors.ShouldHaveCount(2);
65+
66+
ErrorObject error1 = responseDocument.Errors[0];
67+
error1.StatusCode.Should().Be(HttpStatusCode.BadRequest);
68+
error1.Links.ShouldNotBeNull();
69+
error1.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.1");
70+
error1.Title.Should().Be("One or more validation errors occurred.");
71+
error1.Detail.Should().Be("The Name field is required.");
72+
error1.Source.Should().BeNull();
73+
74+
ErrorObject error2 = responseDocument.Errors[1];
75+
error2.StatusCode.Should().Be(HttpStatusCode.BadRequest);
76+
error2.Links.ShouldNotBeNull();
77+
error2.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.1");
78+
error2.Title.Should().Be("One or more validation errors occurred.");
79+
error2.Detail.Should().Be("The field YearOfBirth must be between 1900 and 2050.");
80+
error2.Source.Should().BeNull();
81+
}
3882
}

test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.ComponentModel.DataAnnotations;
12
using JetBrains.Annotations;
23
using JsonApiDotNetCore.Resources;
34
using JsonApiDotNetCore.Resources.Annotations;
@@ -10,4 +11,8 @@ public sealed class Civilian : Identifiable<int>
1011
{
1112
[Attr]
1213
public string Name { get; set; } = null!;
14+
15+
[Attr]
16+
[Range(1900, 2050)]
17+
public int YearOfBirth { get; set; }
1318
}

0 commit comments

Comments
 (0)