Skip to content

Commit f7f8f8b

Browse files
authored
Merge pull request #1450 from json-api-dotnet/openapi-filter-endpoints
OpenAPI: Filter endpoints based on GenerateControllerEndpoints usage
2 parents 6c9aff8 + 71db1dd commit f7f8f8b

14 files changed

+330
-24
lines changed

src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs

-1
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

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

+53-11
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)

src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs

+5-11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
using JsonApiDotNetCore.Middleware;
21
using JsonApiDotNetCore.OpenApi.SwaggerComponents;
32
using Microsoft.AspNetCore.Mvc;
43
using Microsoft.AspNetCore.Mvc.ApiExplorer;
5-
using Microsoft.AspNetCore.Mvc.Infrastructure;
64
using Microsoft.Extensions.DependencyInjection;
75
using Microsoft.Extensions.DependencyInjection.Extensions;
86
using Microsoft.Extensions.Options;
@@ -36,18 +34,14 @@ public static void AddOpenApi(this IServiceCollection services, IMvcCoreBuilder
3634
private static void AddCustomApiExplorer(IServiceCollection services, IMvcCoreBuilder mvcBuilder)
3735
{
3836
services.TryAddSingleton<ResourceFieldValidationMetadataProvider>();
37+
services.AddSingleton<JsonApiActionDescriptorCollectionProvider>();
3938

40-
services.TryAddSingleton<IApiDescriptionGroupCollectionProvider>(provider =>
39+
services.TryAddSingleton<IApiDescriptionGroupCollectionProvider>(serviceProvider =>
4140
{
42-
var controllerResourceMapping = provider.GetRequiredService<IControllerResourceMapping>();
43-
var actionDescriptorCollectionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
44-
var apiDescriptionProviders = provider.GetRequiredService<IEnumerable<IApiDescriptionProvider>>();
45-
var resourceFieldValidationMetadataProvider = provider.GetRequiredService<ResourceFieldValidationMetadataProvider>();
41+
var actionDescriptorCollectionProvider = serviceProvider.GetRequiredService<JsonApiActionDescriptorCollectionProvider>();
42+
var apiDescriptionProviders = serviceProvider.GetRequiredService<IEnumerable<IApiDescriptionProvider>>();
4643

47-
JsonApiActionDescriptorCollectionProvider jsonApiActionDescriptorCollectionProvider =
48-
new(controllerResourceMapping, actionDescriptorCollectionProvider, resourceFieldValidationMetadataProvider);
49-
50-
return new ApiDescriptionGroupCollectionProvider(jsonApiActionDescriptorCollectionProvider, apiDescriptionProviders);
44+
return new ApiDescriptionGroupCollectionProvider(actionDescriptorCollectionProvider, apiDescriptionProviders);
5145
});
5246

5347
mvcBuilder.AddApiExplorer();
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+
}
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+
}
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+
}
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+
}
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+
}
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+
}
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+
}
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+
}

0 commit comments

Comments
 (0)