diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs index 38b3b6677e..c7a17242ea 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs @@ -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; diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs index ada4153c08..3408159fe8 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs @@ -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); diff --git a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs index fb56e99723..2d54312acf 100644 --- a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs +++ b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs @@ -1,4 +1,6 @@ +using System.Reflection; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.OpenApi.JsonApiMetadata; using JsonApiDotNetCore.Resources.Annotations; @@ -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 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().Any(); + return !resourceType.Relationships.OfType().Any(); } } return false; } - private IReadOnlyCollection 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(); + return resourceAttribute?.GenerateControllerEndpoints ?? JsonApiEndpoints.None; } private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint) diff --git a/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs index 3ccb82d622..4a4787644e 100644 --- a/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs @@ -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; @@ -36,18 +34,14 @@ public static void AddOpenApi(this IServiceCollection services, IMvcCoreBuilder private static void AddCustomApiExplorer(IServiceCollection services, IMvcCoreBuilder mvcBuilder) { services.TryAddSingleton(); + services.AddSingleton(); - services.TryAddSingleton(provider => + services.TryAddSingleton(serviceProvider => { - var controllerResourceMapping = provider.GetRequiredService(); - var actionDescriptorCollectionProvider = provider.GetRequiredService(); - var apiDescriptionProviders = provider.GetRequiredService>(); - var resourceFieldValidationMetadataProvider = provider.GetRequiredService(); + var actionDescriptorCollectionProvider = serviceProvider.GetRequiredService(); + var apiDescriptionProviders = serviceProvider.GetRequiredService>(); - JsonApiActionDescriptorCollectionProvider jsonApiActionDescriptorCollectionProvider = - new(controllerResourceMapping, actionDescriptorCollectionProvider, resourceFieldValidationMetadataProvider); - - return new ApiDescriptionGroupCollectionProvider(jsonApiActionDescriptorCollectionProvider, apiDescriptionProviders); + return new ApiDescriptionGroupCollectionProvider(actionDescriptorCollectionProvider, apiDescriptionProviders); }); mvcBuilder.AddApiExplorer(); diff --git a/test/OpenApiTests/RestrictedControllers/Channel.cs b/test/OpenApiTests/RestrictedControllers/Channel.cs new file mode 100644 index 0000000000..870029d218 --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/Channel.cs @@ -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 +{ + [Attr] + public string? Name { get; set; } + + [HasOne] + public DataStream VideoStream { get; set; } = null!; + + [HasMany] + public ISet AudioStreams { get; set; } = new HashSet(); +} diff --git a/test/OpenApiTests/RestrictedControllers/DataStream.cs b/test/OpenApiTests/RestrictedControllers/DataStream.cs new file mode 100644 index 0000000000..99d666f8ce --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/DataStream.cs @@ -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 +{ + [Attr] + [Required] + public ulong? BytesTransmitted { get; set; } +} diff --git a/test/OpenApiTests/RestrictedControllers/DataStreamController.cs b/test/OpenApiTests/RestrictedControllers/DataStreamController.cs new file mode 100644 index 0000000000..1496d51dfb --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/DataStreamController.cs @@ -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 resourceService) + : BaseJsonApiController(options, resourceGraph, loggerFactory, resourceService) +{ + [HttpGet] + [HttpHead] + public override Task GetAsync(CancellationToken cancellationToken) + { + return base.GetAsync(cancellationToken); + } + + [HttpGet("{id}")] + [HttpHead("{id}")] + public override Task GetAsync(long id, CancellationToken cancellationToken) + { + return base.GetAsync(id, cancellationToken); + } +} diff --git a/test/OpenApiTests/RestrictedControllers/ReadOnlyChannel.cs b/test/OpenApiTests/RestrictedControllers/ReadOnlyChannel.cs new file mode 100644 index 0000000000..50f3094eaa --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/ReadOnlyChannel.cs @@ -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; +} diff --git a/test/OpenApiTests/RestrictedControllers/ReadOnlyResourceChannel.cs b/test/OpenApiTests/RestrictedControllers/ReadOnlyResourceChannel.cs new file mode 100644 index 0000000000..483a83b0d6 --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/ReadOnlyResourceChannel.cs @@ -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; +} diff --git a/test/OpenApiTests/RestrictedControllers/RelationshipChannel.cs b/test/OpenApiTests/RestrictedControllers/RelationshipChannel.cs new file mode 100644 index 0000000000..f24b900af0 --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/RelationshipChannel.cs @@ -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; +} diff --git a/test/OpenApiTests/RestrictedControllers/RestrictionDbContext.cs b/test/OpenApiTests/RestrictedControllers/RestrictionDbContext.cs new file mode 100644 index 0000000000..9aef0b9ddc --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/RestrictionDbContext.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace OpenApiTests.RestrictedControllers; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class RestrictionDbContext(DbContextOptions options) : TestableDbContext(options) +{ + public DbSet DataStreams => Set(); + public DbSet ReadOnlyChannels => Set(); + public DbSet WriteOnlyChannels => Set(); + public DbSet RelationshipChannels => Set(); + public DbSet ReadOnlyResourceChannels => Set(); +} diff --git a/test/OpenApiTests/RestrictedControllers/RestrictionFakers.cs b/test/OpenApiTests/RestrictedControllers/RestrictionFakers.cs new file mode 100644 index 0000000000..bd94c5046c --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/RestrictionFakers.cs @@ -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> _lazyDataStreamFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(stream => stream.BytesTransmitted, faker => faker.Random.ULong())); + + private readonly Lazy> _lazyReadOnlyChannelFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(channel => channel.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazyWriteOnlyChannelFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(channel => channel.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazyRelationshipChannelFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(channel => channel.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazyReadOnlyResourceChannelFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(channel => channel.Name, faker => faker.Lorem.Word())); + + public Faker DataStream => _lazyDataStreamFaker.Value; + public Faker ReadOnlyChannel => _lazyReadOnlyChannelFaker.Value; + public Faker WriteOnlyChannel => _lazyWriteOnlyChannelFaker.Value; + public Faker RelationshipChannel => _lazyRelationshipChannelFaker.Value; + public Faker ReadOnlyResourceChannel => _lazyReadOnlyResourceChannelFaker.Value; +} diff --git a/test/OpenApiTests/RestrictedControllers/RestrictionTests.cs b/test/OpenApiTests/RestrictedControllers/RestrictionTests.cs new file mode 100644 index 0000000000..467d8d6171 --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/RestrictionTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using Humanizer; +using JsonApiDotNetCore.Controllers; +using TestBuildingBlocks; +using Xunit; + +#pragma warning disable AV1532 // Loop statement contains nested loop + +namespace OpenApiTests.RestrictedControllers; + +public sealed class RestrictionTests : IClassFixture, RestrictionDbContext>> +{ + private static readonly JsonApiEndpoints[] KnownEndpoints = + [ + JsonApiEndpoints.GetCollection, + JsonApiEndpoints.GetSingle, + JsonApiEndpoints.GetSecondary, + JsonApiEndpoints.GetRelationship, + JsonApiEndpoints.Post, + JsonApiEndpoints.PostRelationship, + JsonApiEndpoints.Patch, + JsonApiEndpoints.PatchRelationship, + JsonApiEndpoints.Delete, + JsonApiEndpoints.DeleteRelationship + ]; + + private readonly OpenApiTestContext, RestrictionDbContext> _testContext; + + public RestrictionTests(OpenApiTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Theory] + [InlineData(typeof(DataStream), JsonApiEndpoints.GetCollection | JsonApiEndpoints.GetSingle)] + [InlineData(typeof(ReadOnlyChannel), ReadOnlyChannel.ControllerEndpoints)] + [InlineData(typeof(WriteOnlyChannel), WriteOnlyChannel.ControllerEndpoints)] + [InlineData(typeof(RelationshipChannel), RelationshipChannel.ControllerEndpoints)] + [InlineData(typeof(ReadOnlyResourceChannel), ReadOnlyResourceChannel.ControllerEndpoints)] + public async Task Only_expected_endpoints_are_exposed(Type resourceClrType, JsonApiEndpoints expected) + { + // Arrange + string resourceName = resourceClrType.Name.Camelize().Pluralize(); + + var endpointToPathMap = new Dictionary + { + [JsonApiEndpoints.GetCollection] = + [ + $"/{resourceName}.get", + $"/{resourceName}.head" + ], + [JsonApiEndpoints.GetSingle] = + [ + $"/{resourceName}/{{id}}.get", + $"/{resourceName}/{{id}}.head" + ], + [JsonApiEndpoints.GetSecondary] = + [ + $"/{resourceName}/{{id}}/audioStreams.get", + $"/{resourceName}/{{id}}/audioStreams.head", + $"/{resourceName}/{{id}}/videoStream.get", + $"/{resourceName}/{{id}}/videoStream.head" + ], + [JsonApiEndpoints.GetRelationship] = + [ + $"/{resourceName}/{{id}}/relationships/audioStreams.get", + $"/{resourceName}/{{id}}/relationships/audioStreams.head", + $"/{resourceName}/{{id}}/relationships/videoStream.get", + $"/{resourceName}/{{id}}/relationships/videoStream.head" + ], + [JsonApiEndpoints.Post] = [$"/{resourceName}.post"], + [JsonApiEndpoints.PostRelationship] = [$"/{resourceName}/{{id}}/relationships/audioStreams.post"], + [JsonApiEndpoints.Patch] = [$"/{resourceName}/{{id}}.patch"], + [JsonApiEndpoints.PatchRelationship] = + [ + $"/{resourceName}/{{id}}/relationships/audioStreams.patch", + $"/{resourceName}/{{id}}/relationships/videoStream.patch" + ], + [JsonApiEndpoints.Delete] = [$"/{resourceName}/{{id}}.delete"], + [JsonApiEndpoints.DeleteRelationship] = [$"/{resourceName}/{{id}}/relationships/audioStreams.delete"] + }; + + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + foreach (JsonApiEndpoints endpoint in KnownEndpoints.Where(value => expected.HasFlag(value))) + { + string[] pathsExpected = endpointToPathMap[endpoint]; + string[] pathsNotExpected = endpointToPathMap.Values.SelectMany(paths => paths).Except(pathsExpected).ToArray(); + + // Assert + foreach (string path in pathsExpected) + { + document.Should().ContainPath($"paths.{path}"); + } + + foreach (string path in pathsNotExpected) + { + document.Should().NotContainPath($"paths{path}"); + } + } + } +} diff --git a/test/OpenApiTests/RestrictedControllers/WriteOnlyChannel.cs b/test/OpenApiTests/RestrictedControllers/WriteOnlyChannel.cs new file mode 100644 index 0000000000..75c576fb87 --- /dev/null +++ b/test/OpenApiTests/RestrictedControllers/WriteOnlyChannel.cs @@ -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 WriteOnlyChannel : Channel +{ + internal const JsonApiEndpoints ControllerEndpoints = JsonApiEndpoints.Command; +}