diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiDotNetCore.OpenApi.Client.NSwag.csproj b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiDotNetCore.OpenApi.Client.NSwag.csproj index 0a5d7b7263..89c3e65f1c 100644 --- a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiDotNetCore.OpenApi.Client.NSwag.csproj +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiDotNetCore.OpenApi.Client.NSwag.csproj @@ -27,6 +27,7 @@ + diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs index de34115b65..00e24965b5 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs @@ -45,6 +45,8 @@ public ConfigureSwaggerGenOptions(OpenApiOperationIdSelector operationIdSelector public void Configure(SwaggerGenOptions options) { + ArgumentNullException.ThrowIfNull(options); + options.SupportNonNullableReferenceTypes(); options.UseAllOfToExtendReferenceSchemas(); @@ -57,10 +59,10 @@ public void Configure(SwaggerGenOptions options) options.CustomOperationIds(_operationIdSelector.GetOpenApiOperationId); options.CustomSchemaIds(_schemaIdSelector.GetSchemaId); + options.OperationFilter(); options.DocumentFilter(); options.DocumentFilter(); options.DocumentFilter(); - options.OperationFilter(); options.DocumentFilter(); } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs index c99384f7b8..d6a47f1ebc 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs @@ -58,7 +58,7 @@ .. AddJsonApiMetadataToAction(endpoint, endpointMetadataContainer.ResponseMetada if (replacementDescriptorsForEndpoint.Count > 0) { - newDescriptors.InsertRange(newDescriptors.IndexOf(endpoint) - 1, replacementDescriptorsForEndpoint); + newDescriptors.InsertRange(newDescriptors.IndexOf(endpoint), replacementDescriptorsForEndpoint); newDescriptors.Remove(endpoint); } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs index 8a1d8d19a2..514372db11 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs @@ -280,5 +280,10 @@ public static JsonApiEndpointWrapper FromActionModel(ActionModel actionModel) JsonApiEndpoints endpoint = EndpointResolver.Instance.GetEndpoint(actionModel.ActionMethod); return new JsonApiEndpointWrapper(false, endpoint); } + + public override string ToString() + { + return IsAtomicOperationsEndpoint ? "PostOperations" : Value.ToString(); + } } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Bodies/BodySchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Bodies/BodySchemaGenerator.cs index 237d8f5e2f..28110a36fc 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Bodies/BodySchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Bodies/BodySchemaGenerator.cs @@ -45,18 +45,19 @@ public OpenApiSchema GenerateSchema(Type bodyType, SchemaRepository schemaReposi _linksVisibilitySchemaGenerator.UpdateSchemaForTopLevel(bodyType, fullSchema, schemaRepository); - SetJsonApiVersion(fullSchema); + SetJsonApiVersion(fullSchema, schemaRepository); return referenceSchema; } protected abstract OpenApiSchema GenerateBodySchema(Type bodyType, SchemaRepository schemaRepository); - private void SetJsonApiVersion(OpenApiSchema fullSchema) + private void SetJsonApiVersion(OpenApiSchema fullSchema, SchemaRepository schemaRepository) { if (fullSchema.Properties.ContainsKey(JsonApiPropertyName.Jsonapi) && !_options.IncludeJsonApiVersion) { fullSchema.Properties.Remove(JsonApiPropertyName.Jsonapi); + schemaRepository.Schemas.Remove(JsonApiPropertyName.Jsonapi); } } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/LinksVisibilitySchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/LinksVisibilitySchemaGenerator.cs index 606a5d1f42..660e4ee94d 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/LinksVisibilitySchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/LinksVisibilitySchemaGenerator.cs @@ -110,14 +110,17 @@ public void UpdateSchemaForRelationship(Type modelType, OpenApiSchema fullSchema private void UpdateLinksProperty(OpenApiSchema fullSchemaForLinksContainer, LinkTypes visibleLinkTypes, LinkTypes possibleLinkTypes, SchemaRepository schemaRepository) { + OpenApiSchema referenceSchemaForLinks = fullSchemaForLinksContainer.Properties[JsonApiPropertyName.Links].UnwrapLastExtendedSchema(); + if ((visibleLinkTypes & possibleLinkTypes) == 0) { fullSchemaForLinksContainer.Required.Remove(JsonApiPropertyName.Links); fullSchemaForLinksContainer.Properties.Remove(JsonApiPropertyName.Links); + + schemaRepository.Schemas.Remove(referenceSchemaForLinks.Reference.Id); } else if (visibleLinkTypes != possibleLinkTypes) { - OpenApiSchema referenceSchemaForLinks = fullSchemaForLinksContainer.Properties[JsonApiPropertyName.Links].UnwrapLastExtendedSchema(); string linksSchemaId = referenceSchemaForLinks.Reference.Id; if (schemaRepository.Schemas.TryGetValue(linksSchemaId, out OpenApiSchema? fullSchemaForLinks)) diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/StringEnumOrderingFilter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/StringEnumOrderingFilter.cs index 0a610bba27..2342d4feec 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/StringEnumOrderingFilter.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/StringEnumOrderingFilter.cs @@ -27,18 +27,15 @@ private sealed class OpenApiEnumVisitor : OpenApiVisitorBase { public override void Visit(OpenApiSchema schema) { - if (schema.Enum.Count > 0) + if (HasSortAnnotation(schema)) { - if (HasSortAnnotation(schema)) + if (schema.Enum.Count > 1) { - if (schema.Enum.Count > 1) - { - OrderEnumMembers(schema); - } + OrderEnumMembers(schema); } - - schema.Extensions.Remove(RequiresSortKey); } + + schema.Extensions.Remove(RequiresSortKey); } private static bool HasSortAnnotation(OpenApiSchema schema) diff --git a/test/OpenApiTests/AtomicOperations/OperationsTests.cs b/test/OpenApiTests/AtomicOperations/OperationsTests.cs index 749b3d804d..5eee4b8e9e 100644 --- a/test/OpenApiTests/AtomicOperations/OperationsTests.cs +++ b/test/OpenApiTests/AtomicOperations/OperationsTests.cs @@ -29,8 +29,10 @@ public OperationsTests(OpenApiTestContext, O [Fact] public async Task Operations_endpoint_is_exposed() { + // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath("paths./operations.post").Should().BeJson(""" { "tags": [ @@ -125,8 +127,10 @@ public async Task Operations_endpoint_is_exposed() [Fact] public async Task Operations_request_component_schemas_are_exposed() { + // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath("components.schemas").With(schemasElement => { schemasElement.Should().ContainPath("operationsRequestDocument").Should().BeJson(""" @@ -241,8 +245,10 @@ public async Task Operations_request_component_schemas_are_exposed() [Fact] public async Task Operations_response_component_schemas_are_exposed() { + // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath("components.schemas").With(schemasElement => { schemasElement.Should().ContainPath("operationsResponseDocument").Should().BeJson(""" @@ -314,8 +320,10 @@ public async Task Operations_response_component_schemas_are_exposed() [Fact] public async Task Course_operation_component_schemas_are_exposed() { + // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath("components.schemas").With(schemasElement => { // resource operations @@ -782,8 +790,10 @@ public async Task Course_operation_component_schemas_are_exposed() [Fact] public async Task Student_operation_component_schemas_are_exposed() { + // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath("components.schemas").With(schemasElement => { // resource operations @@ -1342,8 +1352,10 @@ public async Task Student_operation_component_schemas_are_exposed() [Fact] public async Task Teacher_operation_component_schemas_are_exposed() { + // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath("components.schemas").With(schemasElement => { // resource operations @@ -1846,8 +1858,10 @@ public async Task Teacher_operation_component_schemas_are_exposed() [Fact] public async Task Enrollment_operation_component_schemas_are_exposed() { + // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath("components.schemas").With(schemasElement => { // resource operations diff --git a/test/OpenApiTests/JsonPathBuilder.cs b/test/OpenApiTests/JsonPathBuilder.cs new file mode 100644 index 0000000000..b3c16e4a65 --- /dev/null +++ b/test/OpenApiTests/JsonPathBuilder.cs @@ -0,0 +1,76 @@ +using System.Collections.ObjectModel; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources.Annotations; + +#pragma warning disable AV1008 // Class should not be static + +namespace OpenApiTests; + +internal static class JsonPathBuilder +{ + public static readonly IReadOnlyCollection KnownEndpoints = + [ + JsonApiEndpoints.GetCollection, + JsonApiEndpoints.GetSingle, + JsonApiEndpoints.GetSecondary, + JsonApiEndpoints.GetRelationship, + JsonApiEndpoints.Post, + JsonApiEndpoints.PostRelationship, + JsonApiEndpoints.Patch, + JsonApiEndpoints.PatchRelationship, + JsonApiEndpoints.Delete, + JsonApiEndpoints.DeleteRelationship + ]; + + public static IReadOnlyDictionary> GetEndpointPaths(ResourceType resourceType) + { + var endpointToPathMap = new Dictionary> + { + [JsonApiEndpoints.GetCollection] = + [ + $"paths./{resourceType.PublicName}.get", + $"paths./{resourceType.PublicName}.head" + ], + [JsonApiEndpoints.GetSingle] = + [ + $"paths./{resourceType.PublicName}/{{id}}.get", + $"paths./{resourceType.PublicName}/{{id}}.head" + ], + [JsonApiEndpoints.GetSecondary] = [], + [JsonApiEndpoints.GetRelationship] = [], + [JsonApiEndpoints.Post] = [$"paths./{resourceType.PublicName}.post"], + [JsonApiEndpoints.PostRelationship] = [], + [JsonApiEndpoints.Patch] = [$"paths./{resourceType.PublicName}/{{id}}.patch"], + [JsonApiEndpoints.PatchRelationship] = [], + [JsonApiEndpoints.Delete] = [$"paths./{resourceType.PublicName}/{{id}}.delete"], + [JsonApiEndpoints.DeleteRelationship] = [] + }; + + foreach (RelationshipAttribute relationship in resourceType.Relationships) + { + endpointToPathMap[JsonApiEndpoints.GetSecondary].AddRange([ + $"paths./{resourceType.PublicName}/{{id}}/{relationship.PublicName}.get", + $"paths./{resourceType.PublicName}/{{id}}/{relationship.PublicName}.head" + ]); + + endpointToPathMap[JsonApiEndpoints.GetRelationship].AddRange([ + $"paths./{resourceType.PublicName}/{{id}}/relationships/{relationship.PublicName}.get", + $"paths./{resourceType.PublicName}/{{id}}/relationships/{relationship.PublicName}.head" + ]); + + endpointToPathMap[JsonApiEndpoints.PatchRelationship].Add($"paths./{resourceType.PublicName}/{{id}}/relationships/{relationship.PublicName}.patch"); + + if (relationship is HasManyAttribute) + { + endpointToPathMap[JsonApiEndpoints.PostRelationship].Add( + $"paths./{resourceType.PublicName}/{{id}}/relationships/{relationship.PublicName}.post"); + + endpointToPathMap[JsonApiEndpoints.DeleteRelationship].Add( + $"paths./{resourceType.PublicName}/{{id}}/relationships/{relationship.PublicName}.delete"); + } + } + + return endpointToPathMap.ToDictionary(pair => pair.Key, pair => pair.Value.AsReadOnly()).AsReadOnly(); + } +} diff --git a/test/OpenApiTests/QueryStrings/QueryStringTests.cs b/test/OpenApiTests/QueryStrings/QueryStringTests.cs index f26f3833f5..61ffc007ab 100644 --- a/test/OpenApiTests/QueryStrings/QueryStringTests.cs +++ b/test/OpenApiTests/QueryStrings/QueryStringTests.cs @@ -39,6 +39,7 @@ public async Task Endpoints_have_query_string_parameter(string endpointPath) // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath($"paths.{endpointPath}").With(verbElement => { verbElement.Should().ContainPath("parameters").With(parametersElement => @@ -76,6 +77,7 @@ public async Task Endpoints_do_not_have_query_string_parameter(string endpointPa // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + // Assert document.Should().ContainPath($"paths.{endpointPath}").With(verbElement => { verbElement.Should().ContainPath("parameters").With(parametersElement => diff --git a/test/OpenApiTests/RestrictedControllers/RestrictionTests.cs b/test/OpenApiTests/RestrictedControllers/RestrictionTests.cs index ef52a5529e..2938f6867e 100644 --- a/test/OpenApiTests/RestrictedControllers/RestrictionTests.cs +++ b/test/OpenApiTests/RestrictedControllers/RestrictionTests.cs @@ -1,29 +1,15 @@ +using System.Collections.ObjectModel; using System.Text.Json; -using Humanizer; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using Microsoft.Extensions.DependencyInjection; 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) @@ -48,69 +34,27 @@ public RestrictionTests(OpenApiTestContext, 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}}/ultraHighDefinitionVideoStream.get", - $"/{resourceName}/{{id}}/ultraHighDefinitionVideoStream.head", - $"/{resourceName}/{{id}}/videoStream.get", - $"/{resourceName}/{{id}}/videoStream.head" - ], - [JsonApiEndpoints.GetRelationship] = - [ - $"/{resourceName}/{{id}}/relationships/audioStreams.get", - $"/{resourceName}/{{id}}/relationships/audioStreams.head", - $"/{resourceName}/{{id}}/relationships/ultraHighDefinitionVideoStream.get", - $"/{resourceName}/{{id}}/relationships/ultraHighDefinitionVideoStream.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/ultraHighDefinitionVideoStream.patch", - $"/{resourceName}/{{id}}/relationships/videoStream.patch" - ], - [JsonApiEndpoints.Delete] = [$"/{resourceName}/{{id}}.delete"], - [JsonApiEndpoints.DeleteRelationship] = [$"/{resourceName}/{{id}}/relationships/audioStreams.delete"] - }; + var resourceGraph = _testContext.Factory.Services.GetRequiredService(); + ResourceType resourceType = resourceGraph.GetResourceType(resourceClrType); + IReadOnlyDictionary> endpointToPathMap = JsonPathBuilder.GetEndpointPaths(resourceType); // 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 + string[] pathsExpected = JsonPathBuilder.KnownEndpoints.Where(endpoint => expected.HasFlag(endpoint)) + .SelectMany(endpoint => endpointToPathMap[endpoint]).ToArray(); - // Assert - foreach (string path in pathsExpected) - { - document.Should().ContainPath($"paths.{path}"); - } + string[] pathsNotExpected = endpointToPathMap.Values.SelectMany(paths => paths).Except(pathsExpected).ToArray(); - foreach (string path in pathsNotExpected) - { - document.Should().NotContainPath($"paths{path}"); - } + foreach (string path in pathsExpected) + { + document.Should().ContainPath(path); + } + + foreach (string path in pathsNotExpected) + { + document.Should().NotContainPath(path); } } } diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index c895765a3e..4876388c3a 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -106,17 +106,32 @@ private static void SetDbContextDebugOptions(DbContextOptionsBuilder options) public void ConfigureLogging(Action loggingConfiguration) { + if (_loggingConfiguration != null && _loggingConfiguration != loggingConfiguration) + { + throw new InvalidOperationException($"Do not call {nameof(ConfigureLogging)} multiple times."); + } + _loggingConfiguration = loggingConfiguration; } public void ConfigureServices(Action configureServices) { + if (_configureServices != null && _configureServices != configureServices) + { + throw new InvalidOperationException($"Do not call {nameof(ConfigureServices)} multiple times."); + } + _configureServices = configureServices; } - public void PostConfigureServices(Action configureServices) + public void PostConfigureServices(Action postConfigureServices) { - _postConfigureServices = configureServices; + if (_postConfigureServices != null && _postConfigureServices != postConfigureServices) + { + throw new InvalidOperationException($"Do not call {nameof(PostConfigureServices)} multiple times."); + } + + _postConfigureServices = postConfigureServices; } public async Task RunOnDatabaseAsync(Func asyncAction)