Skip to content

OpenAPI: Filter endpoints based on GenerateControllerEndpoints usage #1450

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 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ internal sealed class EndpointResolver
{
ArgumentGuard.NotNull(controllerAction);

// This is a temporary work-around to prevent the JsonApiDotNetCoreExample project from crashing upon startup.
if (!IsJsonApiController(controllerAction) || IsOperationsController(controllerAction))
{
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction)

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

ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerAction.ReflectedType);
Expand Down
64 changes: 53 additions & 11 deletions src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Reflection;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.OpenApi.JsonApiMetadata;
using JsonApiDotNetCore.Resources.Annotations;
Expand Down Expand Up @@ -29,48 +31,88 @@ public void Apply(ActionModel action)

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

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

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

SetResponseMetadata(action, endpoint.Value);

SetRequestMetadata(action, endpoint.Value);
}

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

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

if (!IsEndpointAvailable(endpoint, resourceType))
{
IReadOnlyCollection<RelationshipAttribute> relationships = GetRelationshipsOfPrimaryResource(controllerType);
return true;
}

if (!relationships.Any())
if (IsSecondaryOrRelationshipEndpoint(endpoint))
{
if (!resourceType.Relationships.Any())
{
return true;
}

if (endpoint is JsonApiEndpoint.DeleteRelationship or JsonApiEndpoint.PostRelationship)
{
return !relationships.OfType<HasManyAttribute>().Any();
return !resourceType.Relationships.OfType<HasManyAttribute>().Any();
}
}

return false;
}

private IReadOnlyCollection<RelationshipAttribute> GetRelationshipsOfPrimaryResource(Type controllerType)
private static bool IsEndpointAvailable(JsonApiEndpoint endpoint, ResourceType resourceType)
{
ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerType);
JsonApiEndpoints availableEndpoints = GetGeneratedControllerEndpoints(resourceType);

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

return primaryResourceType.Relationships;
// For an overridden JSON:API action method in a partial class to show up, it's flag must be turned on in [Resource].
// Otherwise, it is considered to be an action method that throws because the endpoint is unavailable.
return endpoint switch
{
JsonApiEndpoint.GetCollection => availableEndpoints.HasFlag(JsonApiEndpoints.GetCollection),
JsonApiEndpoint.GetSingle => availableEndpoints.HasFlag(JsonApiEndpoints.GetSingle),
JsonApiEndpoint.GetSecondary => availableEndpoints.HasFlag(JsonApiEndpoints.GetSecondary),
JsonApiEndpoint.GetRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.GetRelationship),
JsonApiEndpoint.Post => availableEndpoints.HasFlag(JsonApiEndpoints.Post),
JsonApiEndpoint.PostRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.PostRelationship),
JsonApiEndpoint.Patch => availableEndpoints.HasFlag(JsonApiEndpoints.Patch),
JsonApiEndpoint.PatchRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.PatchRelationship),
JsonApiEndpoint.Delete => availableEndpoints.HasFlag(JsonApiEndpoints.Delete),
JsonApiEndpoint.DeleteRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.DeleteRelationship),
_ => throw new UnreachableCodeException()
};
}

private static JsonApiEndpoints GetGeneratedControllerEndpoints(ResourceType resourceType)
{
var resourceAttribute = resourceType.ClrType.GetCustomAttribute<ResourceAttribute>();
return resourceAttribute?.GenerateControllerEndpoints ?? JsonApiEndpoints.None;
}

private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint)
Expand Down
16 changes: 5 additions & 11 deletions src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.OpenApi.SwaggerComponents;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -36,18 +34,14 @@ public static void AddOpenApi(this IServiceCollection services, IMvcCoreBuilder
private static void AddCustomApiExplorer(IServiceCollection services, IMvcCoreBuilder mvcBuilder)
{
services.TryAddSingleton<ResourceFieldValidationMetadataProvider>();
services.AddSingleton<JsonApiActionDescriptorCollectionProvider>();

services.TryAddSingleton<IApiDescriptionGroupCollectionProvider>(provider =>
services.TryAddSingleton<IApiDescriptionGroupCollectionProvider>(serviceProvider =>
{
var controllerResourceMapping = provider.GetRequiredService<IControllerResourceMapping>();
var actionDescriptorCollectionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
var apiDescriptionProviders = provider.GetRequiredService<IEnumerable<IApiDescriptionProvider>>();
var resourceFieldValidationMetadataProvider = provider.GetRequiredService<ResourceFieldValidationMetadataProvider>();
var actionDescriptorCollectionProvider = serviceProvider.GetRequiredService<JsonApiActionDescriptorCollectionProvider>();
var apiDescriptionProviders = serviceProvider.GetRequiredService<IEnumerable<IApiDescriptionProvider>>();

JsonApiActionDescriptorCollectionProvider jsonApiActionDescriptorCollectionProvider =
new(controllerResourceMapping, actionDescriptorCollectionProvider, resourceFieldValidationMetadataProvider);

return new ApiDescriptionGroupCollectionProvider(jsonApiActionDescriptorCollectionProvider, apiDescriptionProviders);
return new ApiDescriptionGroupCollectionProvider(actionDescriptorCollectionProvider, apiDescriptionProviders);
});

mvcBuilder.AddApiExplorer();
Expand Down
18 changes: 18 additions & 0 deletions test/OpenApiTests/RestrictedControllers/Channel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace OpenApiTests.RestrictedControllers;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public abstract class Channel : Identifiable<long>
{
[Attr]
public string? Name { get; set; }

[HasOne]
public DataStream VideoStream { get; set; } = null!;

[HasMany]
public ISet<DataStream> AudioStreams { get; set; } = new HashSet<DataStream>();
}
16 changes: 16 additions & 0 deletions test/OpenApiTests/RestrictedControllers/DataStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
using JetBrains.Annotations;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace OpenApiTests.RestrictedControllers;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "OpenApiTests.RestrictedControllers", GenerateControllerEndpoints = JsonApiEndpoints.None)]
public sealed class DataStream : Identifiable<long>
{
[Attr]
[Required]
public ulong? BytesTransmitted { get; set; }
}
26 changes: 26 additions & 0 deletions test/OpenApiTests/RestrictedControllers/DataStreamController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace OpenApiTests.RestrictedControllers;

public sealed class DataStreamController(
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<DataStream, long> resourceService)
: BaseJsonApiController<DataStream, long>(options, resourceGraph, loggerFactory, resourceService)
{
[HttpGet]
[HttpHead]
public override Task<IActionResult> GetAsync(CancellationToken cancellationToken)
{
return base.GetAsync(cancellationToken);
}

[HttpGet("{id}")]
[HttpHead("{id}")]
public override Task<IActionResult> GetAsync(long id, CancellationToken cancellationToken)
{
return base.GetAsync(id, cancellationToken);
}
}
12 changes: 12 additions & 0 deletions test/OpenApiTests/RestrictedControllers/ReadOnlyChannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Resources.Annotations;

namespace OpenApiTests.RestrictedControllers;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "OpenApiTests.RestrictedControllers", GenerateControllerEndpoints = ControllerEndpoints)]
public sealed class ReadOnlyChannel : Channel
{
internal const JsonApiEndpoints ControllerEndpoints = JsonApiEndpoints.Query;
}
12 changes: 12 additions & 0 deletions test/OpenApiTests/RestrictedControllers/ReadOnlyResourceChannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Resources.Annotations;

namespace OpenApiTests.RestrictedControllers;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "OpenApiTests.RestrictedControllers", GenerateControllerEndpoints = ControllerEndpoints)]
public sealed class ReadOnlyResourceChannel : Channel
{
internal const JsonApiEndpoints ControllerEndpoints = JsonApiEndpoints.GetCollection | JsonApiEndpoints.GetSingle | JsonApiEndpoints.GetSecondary;
}
13 changes: 13 additions & 0 deletions test/OpenApiTests/RestrictedControllers/RelationshipChannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Resources.Annotations;

namespace OpenApiTests.RestrictedControllers;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "OpenApiTests.RestrictedControllers", GenerateControllerEndpoints = ControllerEndpoints)]
public sealed class RelationshipChannel : Channel
{
internal const JsonApiEndpoints ControllerEndpoints = JsonApiEndpoints.GetRelationship | JsonApiEndpoints.PostRelationship |
JsonApiEndpoints.PatchRelationship | JsonApiEndpoints.DeleteRelationship;
}
15 changes: 15 additions & 0 deletions test/OpenApiTests/RestrictedControllers/RestrictionDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using TestBuildingBlocks;

namespace OpenApiTests.RestrictedControllers;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class RestrictionDbContext(DbContextOptions<RestrictionDbContext> options) : TestableDbContext(options)
{
public DbSet<DataStream> DataStreams => Set<DataStream>();
public DbSet<ReadOnlyChannel> ReadOnlyChannels => Set<ReadOnlyChannel>();
public DbSet<WriteOnlyChannel> WriteOnlyChannels => Set<WriteOnlyChannel>();
public DbSet<RelationshipChannel> RelationshipChannels => Set<RelationshipChannel>();
public DbSet<ReadOnlyResourceChannel> ReadOnlyResourceChannels => Set<ReadOnlyResourceChannel>();
}
38 changes: 38 additions & 0 deletions test/OpenApiTests/RestrictedControllers/RestrictionFakers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Bogus;
using JetBrains.Annotations;
using TestBuildingBlocks;

// @formatter:wrap_chained_method_calls chop_if_long
// @formatter:wrap_before_first_method_call true

namespace OpenApiTests.RestrictedControllers;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class RestrictionFakers : FakerContainer
{
private readonly Lazy<Faker<DataStream>> _lazyDataStreamFaker = new(() => new Faker<DataStream>()
.UseSeed(GetFakerSeed())
.RuleFor(stream => stream.BytesTransmitted, faker => faker.Random.ULong()));

private readonly Lazy<Faker<ReadOnlyChannel>> _lazyReadOnlyChannelFaker = new(() => new Faker<ReadOnlyChannel>()
.UseSeed(GetFakerSeed())
.RuleFor(channel => channel.Name, faker => faker.Lorem.Word()));

private readonly Lazy<Faker<WriteOnlyChannel>> _lazyWriteOnlyChannelFaker = new(() => new Faker<WriteOnlyChannel>()
.UseSeed(GetFakerSeed())
.RuleFor(channel => channel.Name, faker => faker.Lorem.Word()));

private readonly Lazy<Faker<RelationshipChannel>> _lazyRelationshipChannelFaker = new(() => new Faker<RelationshipChannel>()
.UseSeed(GetFakerSeed())
.RuleFor(channel => channel.Name, faker => faker.Lorem.Word()));

private readonly Lazy<Faker<ReadOnlyResourceChannel>> _lazyReadOnlyResourceChannelFaker = new(() => new Faker<ReadOnlyResourceChannel>()
.UseSeed(GetFakerSeed())
.RuleFor(channel => channel.Name, faker => faker.Lorem.Word()));

public Faker<DataStream> DataStream => _lazyDataStreamFaker.Value;
public Faker<ReadOnlyChannel> ReadOnlyChannel => _lazyReadOnlyChannelFaker.Value;
public Faker<WriteOnlyChannel> WriteOnlyChannel => _lazyWriteOnlyChannelFaker.Value;
public Faker<RelationshipChannel> RelationshipChannel => _lazyRelationshipChannelFaker.Value;
public Faker<ReadOnlyResourceChannel> ReadOnlyResourceChannel => _lazyReadOnlyResourceChannelFaker.Value;
}
Loading