diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e7d3ce2494..14b5da9852 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -22,7 +22,7 @@ Bugs are tracked as [GitHub issues](https://github.com/json-api-dotnet/JsonApiDo Explain the problem and include additional details to help maintainers reproduce the problem: - **Use a clear and descriptive title** for the issue to identify the problem. -- **Describe the exact steps which reproduce the problem** in as many details as possible. When listing steps, don't just say what you did, but explain how you did it. +- **Describe the exact steps which reproduce the problem** in as many details as possible. When listing steps, don't just say what you did, but explain how you did it. - **Provide specific examples to demonstrate the steps.** Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://docs.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks). - **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. Explain which behavior you expected to see instead and why. - **If you're reporting a crash**, include the full exception stack trace. diff --git a/README.md b/README.md index 0027580d78..f73d1d85d6 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ To try it out, follow the steps below: In the command above: - Replace YOUR-GITHUB-USERNAME with the username you use to login your GitHub account. - Replace YOUR-PAT-CLASSIC with the token your created above. - + :warning: If the above command doesn't give you access in the next step, remove the package source by running: ```bash dotnet nuget remove source github-json-api diff --git a/docs/ext/openapi/index.md b/docs/ext/openapi/index.md index 99ac49c3f2..20aad7b305 100644 --- a/docs/ext/openapi/index.md +++ b/docs/ext/openapi/index.md @@ -78,7 +78,7 @@ Here's how an article (i.e. a resource of type "articles") might appear in a doc ### Atomic Operations -In addition to the members allowed by the [Atomic Operations extension](https://jsonapi.org/ext/atomic/), +In addition to the members allowed by the [Atomic Operations extension](https://jsonapi.org/ext/atomic/), the following member MAY be included in elements of an `atomic:operations` array: * `openapi:discriminator` - A free-format string to facilitate generation of client code. diff --git a/docs/usage/extensibility/middleware.md b/docs/usage/extensibility/middleware.md index 62528893d3..dbbe81699f 100644 --- a/docs/usage/extensibility/middleware.md +++ b/docs/usage/extensibility/middleware.md @@ -3,9 +3,9 @@ The default middleware validates incoming `Content-Type` and `Accept` HTTP headers. Based on routing configuration, it fills `IJsonApiRequest`, an injectable object that contains JSON:API-related information about the request being processed. -It is possible to replace the built-in middleware components by configuring the IoC container and by configuring `MvcOptions`. +It is possible to replace the built-in middleware components by configuring the IoC container and by configuring `MvcOptions`. -## Configuring the IoC container +## Configuring the IoC container The following example replaces the internal exception filter with a custom implementation. diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index cf5400b722..644d43fb75 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -29,7 +29,7 @@ For various reasons (see examples below) you may need to change parts of the que `JsonApiResourceDefinition` (which is an empty implementation of `IResourceDefinition`) provides overridable methods that pass you the result of query string parameter parsing. The value returned by you determines what will be used to execute the query. -An intermediate format (`QueryExpression` and derived types) is used, which enables us to separate JSON:API implementation +An intermediate format (`QueryExpression` and derived types) is used, which enables us to separate JSON:API implementation from Entity Framework Core `IQueryable` execution. ### Excluding fields @@ -220,7 +220,7 @@ You can define additional query string parameters with the LINQ expression that If the key is present in a query string, the supplied LINQ expression will be added to the database query. > [!NOTE] -> This directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of Entity Framework Core operators. +> This directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of Entity Framework Core operators. But it only works on primary resource endpoints (for example: /articles, but not on /blogs/1/articles or /blogs?include=articles). ```c# diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md index 8a568078a0..05c3066644 100644 --- a/docs/usage/reading/filtering.md +++ b/docs/usage/reading/filtering.md @@ -60,7 +60,7 @@ The next request returns all customers that have orders -or- whose last name is GET /customers?filter=has(orders)&filter=equals(lastName,'Smith') HTTP/1.1 ``` -Aside from filtering on the resource being requested (which would be blogs in /blogs and articles in /blogs/1/articles), +Aside from filtering on the resource being requested (which would be blogs in /blogs and articles in /blogs/1/articles), filtering on to-many relationships can be done using bracket notation: ```http diff --git a/docs/usage/resource-graph.md b/docs/usage/resource-graph.md index 18a13da907..046daaf7f5 100644 --- a/docs/usage/resource-graph.md +++ b/docs/usage/resource-graph.md @@ -14,7 +14,7 @@ There are three ways the resource graph can be created: 2. Specifying an entire DbContext 3. Manually specifying each resource -It is also possible to combine the three of them at once. Be aware that some configuration might overlap, +It is also possible to combine the three of them at once. Be aware that some configuration might overlap, for example one could manually add a resource to the graph which is also auto-discovered. In such a scenario, the configuration is prioritized by the list above in descending order. diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 689b3aa4d2..0ca93e5b15 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -261,8 +261,8 @@ public class TodoItem : Identifiable _since v5.1_ -Default JSON:API relationship capabilities are specified in -@JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultHasOneCapabilities and +Default JSON:API relationship capabilities are specified in +@JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultHasOneCapabilities and @JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultHasManyCapabilities: ```c# diff --git a/docs/usage/writing/bulk-batch-operations.md b/docs/usage/writing/bulk-batch-operations.md index 5756755b51..c8ba2bf48e 100644 --- a/docs/usage/writing/bulk-batch-operations.md +++ b/docs/usage/writing/bulk-batch-operations.md @@ -28,7 +28,7 @@ public sealed class OperationsController : JsonApiOperationsController } ``` -> [!IMPORTANT] +> [!IMPORTANT] > Since v5.6.0, the set of exposed operations is based on > [`GenerateControllerEndpoints` usage](~/usage/extensibility/controllers.md#resource-access-control). > Earlier versions always exposed all operations for all resource types. diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index 141800cdfd..f7843b12cf 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -96,18 +96,25 @@ public QueryLayerComposer(IEnumerable constraintProvid // @formatter:wrap_chained_method_calls restore FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType); - FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType); - FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, hasManyRelationship, inverseRelationship); + if (primaryFilter != null && inverseRelationship is HasOneAttribute) + { + // We can't lift the field chains in a primary filter, because there's no way for a custom filter expression to express + // the scope of its chains. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1671. + return null; + } + + FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType); + FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, primaryFilter, hasManyRelationship, inverseRelationship); - return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, primaryFilter, secondaryFilter); + return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, secondaryFilter); } - private static FilterExpression GetInverseRelationshipFilter([DisallowNull] TId primaryId, HasManyAttribute relationship, - RelationshipAttribute inverseRelationship) + private static FilterExpression GetInverseRelationshipFilter([DisallowNull] TId primaryId, FilterExpression? primaryFilter, + HasManyAttribute relationship, RelationshipAttribute inverseRelationship) { return inverseRelationship is HasManyAttribute hasManyInverseRelationship - ? GetInverseHasManyRelationshipFilter(primaryId, relationship, hasManyInverseRelationship) + ? GetInverseHasManyRelationshipFilter(primaryId, primaryFilter, relationship, hasManyInverseRelationship) : GetInverseHasOneRelationshipFilter(primaryId, relationship, (HasOneAttribute)inverseRelationship); } @@ -120,14 +127,15 @@ private static ComparisonExpression GetInverseHasOneRelationshipFilter([Dis return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId)); } - private static HasExpression GetInverseHasManyRelationshipFilter([DisallowNull] TId primaryId, HasManyAttribute relationship, - HasManyAttribute inverseRelationship) + private static HasExpression GetInverseHasManyRelationshipFilter([DisallowNull] TId primaryId, FilterExpression? primaryFilter, + HasManyAttribute relationship, HasManyAttribute inverseRelationship) { AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId)); - return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison); + FilterExpression filter = LogicalExpression.Compose(LogicalOperator.And, idComparison, primaryFilter)!; + return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), filter); } /// diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index 8bc67c0196..d20bdd5f0d 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -226,11 +226,11 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } } - attributes.Add(attributeName, attributeValue); + attributes[attributeName] = attributeValue; } else { - attributes.Add(attributeName, null); + attributes[attributeName] = null; reader.Skip(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Constellation.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Constellation.cs new file mode 100644 index 0000000000..371c72bcf8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Constellation.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading")] +public sealed class Constellation : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + [Required] + public Season? VisibleDuring { get; set; } + + [HasMany] + public ISet Stars { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ConstellationDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ConstellationDefinition.cs new file mode 100644 index 0000000000..b74d3c4947 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ConstellationDefinition.cs @@ -0,0 +1,33 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class ConstellationDefinition( + IResourceGraph resourceGraph, IClientSettingsProvider clientSettingsProvider, ResourceDefinitionHitCounter hitCounter) + : HitCountingResourceDefinition(resourceGraph, hitCounter) +{ + private readonly IClientSettingsProvider _clientSettingsProvider = clientSettingsProvider; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Reading; + + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + FilterExpression? baseFilter = base.OnApplyFilter(existingFilter); + + if (_clientSettingsProvider.AreConstellationsVisibleDuringWinterHidden) + { + AttrAttribute visibleDuringAttribute = ResourceType.GetAttributeByPropertyName(nameof(Constellation.VisibleDuring)); + var visibleDuringChain = new ResourceFieldChainExpression(visibleDuringAttribute); + var visibleDuringComparison = new ComparisonExpression(ComparisonOperator.Equals, visibleDuringChain, new LiteralConstantExpression(Season.Winter)); + var notVisibleDuringComparison = new NotExpression(visibleDuringComparison); + + return LogicalExpression.Compose(LogicalOperator.And, baseFilter, notVisibleDuringComparison); + } + + return baseFilter; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/IClientSettingsProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/IClientSettingsProvider.cs index f67cd3d993..2200921901 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/IClientSettingsProvider.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/IClientSettingsProvider.cs @@ -2,6 +2,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; public interface IClientSettingsProvider { + bool AreVeryLargeStarsHidden { get; } + bool AreConstellationsVisibleDuringWinterHidden { get; } bool IsIncludePlanetMoonsBlocked { get; } bool ArePlanetsWithPrivateNameHidden { get; } bool IsStarGivingLightToMoonAutoIncluded { get; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs index 64a298555f..c22619a825 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs @@ -17,12 +17,14 @@ public ResourceDefinitionReadTests(IntegrationTestContext(); testContext.UseController(); testContext.UseController(); testContext.UseController(); testContext.ConfigureServices(services => { + services.AddResourceDefinition(); services.AddResourceDefinition(); services.AddResourceDefinition(); services.AddResourceDefinition(); @@ -323,7 +325,6 @@ public async Task Filter_from_resource_definition_is_applied_at_secondary_endpoi await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); dbContext.Stars.Add(star); await dbContext.SaveChangesAsync(); }); @@ -375,7 +376,6 @@ public async Task Filter_from_resource_definition_is_applied_at_relationship_end await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); dbContext.Stars.Add(star); await dbContext.SaveChangesAsync(); }); @@ -409,6 +409,198 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }, options => options.WithStrictOrdering()); } + [Fact] + public async Task No_total_when_resource_definition_has_filter_on_inverse_ManyToOne_at_secondary_endpoint() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService(); + settingsProvider.HideVeryLargeStars(); + + Star star = _fakers.Star.GenerateOne(); + star.Planets = _fakers.Planet.GenerateSet(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stars.Add(star); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/stars/{star.StringId}/planets"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(0).StringId); + + responseDocument.Meta.Should().BeNull(); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta) + }, options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Has_total_when_resource_definition_has_filter_on_inverse_ManyToMany_at_secondary_endpoint() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService(); + settingsProvider.HideConstellationsVisibleDuringWinter(); + + Constellation constellation = _fakers.Constellation.GenerateOne(); + constellation.VisibleDuring = Season.Winter; + constellation.Stars = _fakers.Star.GenerateSet(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Constellations.Add(constellation); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/constellations/{constellation.StringId}/stars"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'constellations' with ID '{constellation.StringId}' does not exist."); + + responseDocument.Meta.Should().ContainTotal(0); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplyFilter) + }, options => options.WithStrictOrdering()); + } + + [Fact] + public async Task No_total_when_resource_definition_has_filter_on_inverse_ManyToOne_at_relationship_endpoint() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService(); + settingsProvider.HideVeryLargeStars(); + + Star star = _fakers.Star.GenerateOne(); + star.Planets = _fakers.Planet.GenerateSet(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stars.Add(star); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/stars/{star.StringId}/relationships/planets"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(0).StringId); + + responseDocument.Meta.Should().BeNull(); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter) + }, options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Has_total_when_resource_definition_has_filter_on_inverse_ManyToMany_at_relationship_endpoint() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService(); + settingsProvider.HideConstellationsVisibleDuringWinter(); + + Constellation constellation = _fakers.Constellation.GenerateOne(); + constellation.VisibleDuring = Season.Winter; + constellation.Stars = _fakers.Star.GenerateSet(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Constellations.Add(constellation); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/constellations/{constellation.StringId}/relationships/stars"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'constellations' with ID '{constellation.StringId}' does not exist."); + + responseDocument.Meta.Should().ContainTotal(0); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplyFilter) + }, options => options.WithStrictOrdering()); + } + [Fact] public async Task Sort_from_resource_definition_is_applied() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Season.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Season.cs new file mode 100644 index 0000000000..16e3c0d9e4 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Season.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum Season +{ + Winter, + Spring, + Summer, + Fall +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Star.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Star.cs index e79c2ae8af..b8c78109ed 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Star.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Star.cs @@ -25,4 +25,7 @@ public sealed class Star : Identifiable [HasMany] public ISet Planets { get; set; } = new HashSet(); + + [HasMany] + public ISet IsPartOf { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs index 944cb4ca0e..3e4f9b678c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs @@ -2,16 +2,35 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] // The constructor parameters will be resolved from the container, which means you can take on any dependency that is also defined in the container. -public sealed class StarDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) +public sealed class StarDefinition(IResourceGraph resourceGraph, IClientSettingsProvider clientSettingsProvider, ResourceDefinitionHitCounter hitCounter) : HitCountingResourceDefinition(resourceGraph, hitCounter) { + private readonly IClientSettingsProvider _clientSettingsProvider = clientSettingsProvider; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Reading; + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + FilterExpression? baseFilter = base.OnApplyFilter(existingFilter); + + if (_clientSettingsProvider.AreVeryLargeStarsHidden) + { + AttrAttribute solarRadiusAttribute = ResourceType.GetAttributeByPropertyName(nameof(Star.SolarRadius)); + var solarRadiusChain = new ResourceFieldChainExpression(solarRadiusAttribute); + var solarRadiusComparison = new ComparisonExpression(ComparisonOperator.LessThan, solarRadiusChain, new LiteralConstantExpression(2000M)); + + return LogicalExpression.Compose(LogicalOperator.And, baseFilter, solarRadiusComparison); + } + + return baseFilter; + } + public override SortExpression OnApplySort(SortExpression? existingSort) { base.OnApplySort(existingSort); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/TestClientSettingsProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/TestClientSettingsProvider.cs index 0efc7a415e..65fa84a415 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/TestClientSettingsProvider.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/TestClientSettingsProvider.cs @@ -2,17 +2,31 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; internal sealed class TestClientSettingsProvider : IClientSettingsProvider { + public bool AreVeryLargeStarsHidden { get; private set; } + public bool AreConstellationsVisibleDuringWinterHidden { get; private set; } public bool IsIncludePlanetMoonsBlocked { get; private set; } public bool ArePlanetsWithPrivateNameHidden { get; private set; } public bool IsStarGivingLightToMoonAutoIncluded { get; private set; } public void ResetToDefaults() { + AreVeryLargeStarsHidden = false; + AreConstellationsVisibleDuringWinterHidden = false; IsIncludePlanetMoonsBlocked = false; ArePlanetsWithPrivateNameHidden = false; IsStarGivingLightToMoonAutoIncluded = false; } + public void HideVeryLargeStars() + { + AreVeryLargeStarsHidden = true; + } + + public void HideConstellationsVisibleDuringWinter() + { + AreConstellationsVisibleDuringWinterHidden = true; + } + public void BlockIncludePlanetMoons() { IsIncludePlanetMoonsBlocked = true; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseDbContext.cs index 94e1b73ec0..6a157ed8b2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseDbContext.cs @@ -8,6 +8,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; public sealed class UniverseDbContext(DbContextOptions options) : TestableDbContext(options) { + public DbSet Constellations => Set(); public DbSet Stars => Set(); public DbSet Planets => Set(); public DbSet Moons => Set(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseFakers.cs index 4c0723e1df..86ca6a70cf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseFakers.cs @@ -8,6 +8,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading; internal sealed class UniverseFakers { + private readonly Lazy> _lazyConstellationFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(constellation => constellation.Name, faker => faker.Random.Word()) + .RuleFor(constellation => constellation.VisibleDuring, faker => faker.PickRandom())); + private readonly Lazy> _lazyStarFaker = new(() => new Faker() .MakeDeterministic() .RuleFor(star => star.Name, faker => faker.Random.Word()) @@ -27,6 +32,7 @@ internal sealed class UniverseFakers .RuleFor(moon => moon.Name, faker => faker.Random.Word()) .RuleFor(moon => moon.SolarRadius, faker => faker.Random.Decimal(.01M, 1000M))); + public Faker Constellation => _lazyConstellationFaker.Value; public Faker Star => _lazyStarFaker.Value; public Faker Planet => _lazyPlanetFaker.Value; public Faker Moon => _lazyMoonFaker.Value;