Skip to content

Commit 61ab4a9

Browse files
committed
Fixed: set visbility of JSON:API action methods based on GenerateControllerEndpoints
1 parent 6c9aff8 commit 61ab4a9

13 files changed

+325
-13
lines changed

src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ internal sealed class EndpointResolver
1111
{
1212
ArgumentGuard.NotNull(controllerAction);
1313

14-
// This is a temporary work-around to prevent the JsonApiDotNetCoreExample project from crashing upon startup.
1514
if (!IsJsonApiController(controllerAction) || IsOperationsController(controllerAction))
1615
{
1716
return null;

src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction)
3434

3535
if (endpoint == null)
3636
{
37-
throw new NotSupportedException($"Unable to provide metadata for non-JsonApiDotNetCore endpoint '{controllerAction.ReflectedType!.FullName}'.");
37+
throw new NotSupportedException($"Unable to provide metadata for non-JSON:API endpoint '{controllerAction.ReflectedType!.FullName}'.");
3838
}
3939

4040
ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerAction.ReflectedType);

src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Reflection;
12
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Controllers;
24
using JsonApiDotNetCore.Middleware;
35
using JsonApiDotNetCore.OpenApi.JsonApiMetadata;
46
using JsonApiDotNetCore.Resources.Annotations;
@@ -29,48 +31,88 @@ public void Apply(ActionModel action)
2931

3032
JsonApiEndpoint? endpoint = _endpointResolver.Get(action.ActionMethod);
3133

32-
if (endpoint == null || ShouldSuppressEndpoint(endpoint.Value, action.Controller.ControllerType))
34+
if (endpoint == null)
3335
{
36+
// Not a JSON:API controller, or a non-standard action method in a JSON:API controller, or an atomic:operations
37+
// controller. None of these are yet implemented, so hide them to avoid downstream crashes.
3438
action.ApiExplorer.IsVisible = false;
39+
return;
40+
}
3541

42+
if (ShouldSuppressEndpoint(endpoint.Value, action.Controller.ControllerType))
43+
{
44+
action.ApiExplorer.IsVisible = false;
3645
return;
3746
}
3847

3948
SetResponseMetadata(action, endpoint.Value);
40-
4149
SetRequestMetadata(action, endpoint.Value);
4250
}
4351

4452
private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, Type controllerType)
4553
{
46-
if (IsSecondaryOrRelationshipEndpoint(endpoint))
54+
ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(controllerType);
55+
56+
if (resourceType == null)
57+
{
58+
throw new UnreachableCodeException();
59+
}
60+
61+
if (!IsEndpointAvailable(endpoint, resourceType))
4762
{
48-
IReadOnlyCollection<RelationshipAttribute> relationships = GetRelationshipsOfPrimaryResource(controllerType);
63+
return true;
64+
}
4965

50-
if (!relationships.Any())
66+
if (IsSecondaryOrRelationshipEndpoint(endpoint))
67+
{
68+
if (!resourceType.Relationships.Any())
5169
{
5270
return true;
5371
}
5472

5573
if (endpoint is JsonApiEndpoint.DeleteRelationship or JsonApiEndpoint.PostRelationship)
5674
{
57-
return !relationships.OfType<HasManyAttribute>().Any();
75+
return !resourceType.Relationships.OfType<HasManyAttribute>().Any();
5876
}
5977
}
6078

6179
return false;
6280
}
6381

64-
private IReadOnlyCollection<RelationshipAttribute> GetRelationshipsOfPrimaryResource(Type controllerType)
82+
private static bool IsEndpointAvailable(JsonApiEndpoint endpoint, ResourceType resourceType)
6583
{
66-
ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerType);
84+
JsonApiEndpoints availableEndpoints = GetGeneratedControllerEndpoints(resourceType);
6785

68-
if (primaryResourceType == null)
86+
if (availableEndpoints == JsonApiEndpoints.None)
6987
{
70-
throw new UnreachableCodeException();
88+
// Auto-generated controllers are disabled, so we can't know what to hide.
89+
// It is assumed that a handwritten JSON:API controller only provides action methods for what it supports.
90+
// To accomplish that, derive from BaseJsonApiController instead of JsonApiController.
91+
return true;
7192
}
7293

73-
return primaryResourceType.Relationships;
94+
// For an overridden JSON:API action method in a partial class to show up, it's flag must be turned on in [Resource].
95+
// Otherwise, it is considered to be an action method that throws because the endpoint is unavailable.
96+
return endpoint switch
97+
{
98+
JsonApiEndpoint.GetCollection => availableEndpoints.HasFlag(JsonApiEndpoints.GetCollection),
99+
JsonApiEndpoint.GetSingle => availableEndpoints.HasFlag(JsonApiEndpoints.GetSingle),
100+
JsonApiEndpoint.GetSecondary => availableEndpoints.HasFlag(JsonApiEndpoints.GetSecondary),
101+
JsonApiEndpoint.GetRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.GetRelationship),
102+
JsonApiEndpoint.Post => availableEndpoints.HasFlag(JsonApiEndpoints.Post),
103+
JsonApiEndpoint.PostRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.PostRelationship),
104+
JsonApiEndpoint.Patch => availableEndpoints.HasFlag(JsonApiEndpoints.Patch),
105+
JsonApiEndpoint.PatchRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.PatchRelationship),
106+
JsonApiEndpoint.Delete => availableEndpoints.HasFlag(JsonApiEndpoints.Delete),
107+
JsonApiEndpoint.DeleteRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.DeleteRelationship),
108+
_ => throw new UnreachableCodeException()
109+
};
110+
}
111+
112+
private static JsonApiEndpoints GetGeneratedControllerEndpoints(ResourceType resourceType)
113+
{
114+
var resourceAttribute = resourceType.ClrType.GetCustomAttribute<ResourceAttribute>();
115+
return resourceAttribute?.GenerateControllerEndpoints ?? JsonApiEndpoints.None;
74116
}
75117

76118
private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace OpenApiTests.RestrictedControllers;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
public abstract class Channel : Identifiable<long>
9+
{
10+
[Attr]
11+
public string? Name { get; set; }
12+
13+
[HasOne]
14+
public DataStream VideoStream { get; set; } = null!;
15+
16+
[HasMany]
17+
public ISet<DataStream> AudioStreams { get; set; } = new HashSet<DataStream>();
18+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using JetBrains.Annotations;
3+
using JsonApiDotNetCore.Controllers;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
7+
namespace OpenApiTests.RestrictedControllers;
8+
9+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
10+
[Resource(ControllerNamespace = "OpenApiTests.RestrictedControllers", GenerateControllerEndpoints = JsonApiEndpoints.None)]
11+
public sealed class DataStream : Identifiable<long>
12+
{
13+
[Attr]
14+
[Required]
15+
public ulong? BytesTransmitted { get; set; }
16+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using JsonApiDotNetCore.Configuration;
2+
using JsonApiDotNetCore.Controllers;
3+
using JsonApiDotNetCore.Services;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace OpenApiTests.RestrictedControllers;
8+
9+
public sealed class DataStreamController(
10+
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<DataStream, long> resourceService)
11+
: BaseJsonApiController<DataStream, long>(options, resourceGraph, loggerFactory, resourceService)
12+
{
13+
[HttpGet]
14+
[HttpHead]
15+
public override Task<IActionResult> GetAsync(CancellationToken cancellationToken)
16+
{
17+
return base.GetAsync(cancellationToken);
18+
}
19+
20+
[HttpGet("{id}")]
21+
[HttpHead("{id}")]
22+
public override Task<IActionResult> GetAsync(long id, CancellationToken cancellationToken)
23+
{
24+
return base.GetAsync(id, cancellationToken);
25+
}
26+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Controllers;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace OpenApiTests.RestrictedControllers;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "OpenApiTests.RestrictedControllers", GenerateControllerEndpoints = ControllerEndpoints)]
9+
public sealed class ReadOnlyChannel : Channel
10+
{
11+
internal const JsonApiEndpoints ControllerEndpoints = JsonApiEndpoints.Query;
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Controllers;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace OpenApiTests.RestrictedControllers;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "OpenApiTests.RestrictedControllers", GenerateControllerEndpoints = ControllerEndpoints)]
9+
public sealed class ReadOnlyResourceChannel : Channel
10+
{
11+
internal const JsonApiEndpoints ControllerEndpoints = JsonApiEndpoints.GetCollection | JsonApiEndpoints.GetSingle | JsonApiEndpoints.GetSecondary;
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Controllers;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace OpenApiTests.RestrictedControllers;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "OpenApiTests.RestrictedControllers", GenerateControllerEndpoints = ControllerEndpoints)]
9+
public sealed class RelationshipChannel : Channel
10+
{
11+
internal const JsonApiEndpoints ControllerEndpoints = JsonApiEndpoints.GetRelationship | JsonApiEndpoints.PostRelationship |
12+
JsonApiEndpoints.PatchRelationship | JsonApiEndpoints.DeleteRelationship;
13+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using JetBrains.Annotations;
2+
using Microsoft.EntityFrameworkCore;
3+
using TestBuildingBlocks;
4+
5+
namespace OpenApiTests.RestrictedControllers;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
public sealed class RestrictionDbContext(DbContextOptions<RestrictionDbContext> options) : TestableDbContext(options)
9+
{
10+
public DbSet<DataStream> DataStreams => Set<DataStream>();
11+
public DbSet<ReadOnlyChannel> ReadOnlyChannels => Set<ReadOnlyChannel>();
12+
public DbSet<WriteOnlyChannel> WriteOnlyChannels => Set<WriteOnlyChannel>();
13+
public DbSet<RelationshipChannel> RelationshipChannels => Set<RelationshipChannel>();
14+
public DbSet<ReadOnlyResourceChannel> ReadOnlyResourceChannels => Set<ReadOnlyResourceChannel>();
15+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using Bogus;
2+
using JetBrains.Annotations;
3+
using TestBuildingBlocks;
4+
5+
// @formatter:wrap_chained_method_calls chop_if_long
6+
// @formatter:wrap_before_first_method_call true
7+
8+
namespace OpenApiTests.RestrictedControllers;
9+
10+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
11+
public sealed class RestrictionFakers : FakerContainer
12+
{
13+
private readonly Lazy<Faker<DataStream>> _lazyDataStreamFaker = new(() => new Faker<DataStream>()
14+
.UseSeed(GetFakerSeed())
15+
.RuleFor(stream => stream.BytesTransmitted, faker => faker.Random.ULong()));
16+
17+
private readonly Lazy<Faker<ReadOnlyChannel>> _lazyReadOnlyChannelFaker = new(() => new Faker<ReadOnlyChannel>()
18+
.UseSeed(GetFakerSeed())
19+
.RuleFor(channel => channel.Name, faker => faker.Lorem.Word()));
20+
21+
private readonly Lazy<Faker<WriteOnlyChannel>> _lazyWriteOnlyChannelFaker = new(() => new Faker<WriteOnlyChannel>()
22+
.UseSeed(GetFakerSeed())
23+
.RuleFor(channel => channel.Name, faker => faker.Lorem.Word()));
24+
25+
private readonly Lazy<Faker<RelationshipChannel>> _lazyRelationshipChannelFaker = new(() => new Faker<RelationshipChannel>()
26+
.UseSeed(GetFakerSeed())
27+
.RuleFor(channel => channel.Name, faker => faker.Lorem.Word()));
28+
29+
private readonly Lazy<Faker<ReadOnlyResourceChannel>> _lazyReadOnlyResourceChannelFaker = new(() => new Faker<ReadOnlyResourceChannel>()
30+
.UseSeed(GetFakerSeed())
31+
.RuleFor(channel => channel.Name, faker => faker.Lorem.Word()));
32+
33+
public Faker<DataStream> DataStream => _lazyDataStreamFaker.Value;
34+
public Faker<ReadOnlyChannel> ReadOnlyChannel => _lazyReadOnlyChannelFaker.Value;
35+
public Faker<WriteOnlyChannel> WriteOnlyChannel => _lazyWriteOnlyChannelFaker.Value;
36+
public Faker<RelationshipChannel> RelationshipChannel => _lazyRelationshipChannelFaker.Value;
37+
public Faker<ReadOnlyResourceChannel> ReadOnlyResourceChannel => _lazyReadOnlyResourceChannelFaker.Value;
38+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System.Text.Json;
2+
using Humanizer;
3+
using JsonApiDotNetCore.Controllers;
4+
using TestBuildingBlocks;
5+
using Xunit;
6+
7+
#pragma warning disable AV1532 // Loop statement contains nested loop
8+
9+
namespace OpenApiTests.RestrictedControllers;
10+
11+
public sealed class RestrictionTests : IClassFixture<OpenApiTestContext<OpenApiStartup<RestrictionDbContext>, RestrictionDbContext>>
12+
{
13+
private static readonly JsonApiEndpoints[] KnownEndpoints =
14+
[
15+
JsonApiEndpoints.GetCollection,
16+
JsonApiEndpoints.GetSingle,
17+
JsonApiEndpoints.GetSecondary,
18+
JsonApiEndpoints.GetRelationship,
19+
JsonApiEndpoints.Post,
20+
JsonApiEndpoints.PostRelationship,
21+
JsonApiEndpoints.Patch,
22+
JsonApiEndpoints.PatchRelationship,
23+
JsonApiEndpoints.Delete,
24+
JsonApiEndpoints.DeleteRelationship
25+
];
26+
27+
private readonly OpenApiTestContext<OpenApiStartup<RestrictionDbContext>, RestrictionDbContext> _testContext;
28+
29+
public RestrictionTests(OpenApiTestContext<OpenApiStartup<RestrictionDbContext>, RestrictionDbContext> testContext)
30+
{
31+
_testContext = testContext;
32+
33+
testContext.UseController<DataStreamController>();
34+
testContext.UseController<ReadOnlyChannelsController>();
35+
testContext.UseController<WriteOnlyChannelsController>();
36+
testContext.UseController<RelationshipChannelsController>();
37+
testContext.UseController<ReadOnlyResourceChannelsController>();
38+
}
39+
40+
[Theory]
41+
[InlineData(typeof(DataStream), JsonApiEndpoints.GetCollection | JsonApiEndpoints.GetSingle)]
42+
[InlineData(typeof(ReadOnlyChannel), ReadOnlyChannel.ControllerEndpoints)]
43+
[InlineData(typeof(WriteOnlyChannel), WriteOnlyChannel.ControllerEndpoints)]
44+
[InlineData(typeof(RelationshipChannel), RelationshipChannel.ControllerEndpoints)]
45+
[InlineData(typeof(ReadOnlyResourceChannel), ReadOnlyResourceChannel.ControllerEndpoints)]
46+
public async Task Only_expected_endpoints_are_exposed(Type resourceClrType, JsonApiEndpoints expected)
47+
{
48+
// Arrange
49+
string resourceName = resourceClrType.Name.Camelize().Pluralize();
50+
51+
var endpointToPathMap = new Dictionary<JsonApiEndpoints, string[]>
52+
{
53+
[JsonApiEndpoints.GetCollection] =
54+
[
55+
$"/{resourceName}.get",
56+
$"/{resourceName}.head"
57+
],
58+
[JsonApiEndpoints.GetSingle] =
59+
[
60+
$"/{resourceName}/{{id}}.get",
61+
$"/{resourceName}/{{id}}.head"
62+
],
63+
[JsonApiEndpoints.GetSecondary] =
64+
[
65+
$"/{resourceName}/{{id}}/audioStreams.get",
66+
$"/{resourceName}/{{id}}/audioStreams.head",
67+
$"/{resourceName}/{{id}}/videoStream.get",
68+
$"/{resourceName}/{{id}}/videoStream.head"
69+
],
70+
[JsonApiEndpoints.GetRelationship] =
71+
[
72+
$"/{resourceName}/{{id}}/relationships/audioStreams.get",
73+
$"/{resourceName}/{{id}}/relationships/audioStreams.head",
74+
$"/{resourceName}/{{id}}/relationships/videoStream.get",
75+
$"/{resourceName}/{{id}}/relationships/videoStream.head"
76+
],
77+
[JsonApiEndpoints.Post] = [$"/{resourceName}.post"],
78+
[JsonApiEndpoints.PostRelationship] = [$"/{resourceName}/{{id}}/relationships/audioStreams.post"],
79+
[JsonApiEndpoints.Patch] = [$"/{resourceName}/{{id}}.patch"],
80+
[JsonApiEndpoints.PatchRelationship] =
81+
[
82+
$"/{resourceName}/{{id}}/relationships/audioStreams.patch",
83+
$"/{resourceName}/{{id}}/relationships/videoStream.patch"
84+
],
85+
[JsonApiEndpoints.Delete] = [$"/{resourceName}/{{id}}.delete"],
86+
[JsonApiEndpoints.DeleteRelationship] = [$"/{resourceName}/{{id}}/relationships/audioStreams.delete"]
87+
};
88+
89+
// Act
90+
JsonElement document = await _testContext.GetSwaggerDocumentAsync();
91+
92+
foreach (JsonApiEndpoints endpoint in KnownEndpoints.Where(value => expected.HasFlag(value)))
93+
{
94+
string[] pathsExpected = endpointToPathMap[endpoint];
95+
string[] pathsNotExpected = endpointToPathMap.Values.SelectMany(paths => paths).Except(pathsExpected).ToArray();
96+
97+
// Assert
98+
foreach (string path in pathsExpected)
99+
{
100+
document.Should().ContainPath($"paths.{path}");
101+
}
102+
103+
foreach (string path in pathsNotExpected)
104+
{
105+
document.Should().NotContainPath($"paths{path}");
106+
}
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)