From c325200283ef0a070b0577c548240321e07fb910 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 14 Apr 2021 13:51:16 +0200 Subject: [PATCH 01/22] Simpler registration for resource definitions --- .../ServiceCollectionExtensions.cs | 14 +++ .../Meta/AtomicResourceMetaTests.cs | 8 +- .../QueryStrings/AtomicQueryStringTests.cs | 3 +- ...icSparseFieldSetResourceDefinitionTests.cs | 3 +- .../Meta/ResourceMetaTests.cs | 5 +- .../ResourceDefinitionQueryCallbackTests.cs | 3 +- .../SoftDeletion/SoftDeletionTests.cs | 7 +- .../ServiceCollectionExtensionsTests.cs | 106 ++++++++++++++++++ 8 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 3192ddd85f..815b44bd96 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Client.Internal; using JsonApiDotNetCore.Services; @@ -101,6 +102,19 @@ public static IServiceCollection AddResourceRepository(this IServic return services; } + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource definition interfaces, such as + /// and . + /// + public static IServiceCollection AddResourceDefinition(this IServiceCollection services) + { + ArgumentGuard.NotNull(services, nameof(services)); + + RegisterForConstructedType(services, typeof(TResourceDefinition), ServiceDiscoveryFacade.ResourceDefinitionInterfaces); + + return services; + } + private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable openGenericInterfaces) { bool seenCompatibleInterface = false; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index de71c07d49..453cd86714 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -1,14 +1,12 @@ -using System; using System.Net; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Controllers; using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -27,8 +25,8 @@ public AtomicResourceMetaTests(ExampleIntegrationTestContext { - services.AddScoped, MusicTrackMetaDefinition>(); - services.AddScoped, TextLanguageMetaDefinition>(); + services.AddResourceDefinition(); + services.AddResourceDefinition(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index ebcf1fe882..63e0d007e8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -6,7 +6,6 @@ using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Controllers; using JsonApiDotNetCoreExampleTests.Startups; @@ -38,7 +37,7 @@ public AtomicQueryStringTests(ExampleIntegrationTestContext, MusicTrackReleaseDefinition>(); + services.AddResourceDefinition(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs index 144c0df61a..ba3456584c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Controllers; @@ -28,7 +29,7 @@ public AtomicSparseFieldSetResourceDefinitionTests(ExampleIntegrationTestContext testContext.ConfigureServicesAfterStartup(services => { services.AddSingleton(); - services.AddScoped, LyricTextDefinition>(); + services.AddResourceDefinition(); services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 0378db3434..bdf5efec57 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -3,10 +3,9 @@ using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -26,7 +25,7 @@ public ResourceMetaTests(ExampleIntegrationTestContext { - services.AddScoped, SupportTicketDefinition>(); + services.AddResourceDefinition(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index 204154325a..26a09d690e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -5,7 +5,6 @@ using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; @@ -27,7 +26,7 @@ public ResourceDefinitionQueryCallbackTests(ExampleIntegrationTestContext { - services.AddScoped, CallableResourceDefinition>(); + services.AddResourceDefinition(); services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 8128d02d29..9484c7175d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -4,10 +4,9 @@ using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -26,8 +25,8 @@ public SoftDeletionTests(ExampleIntegrationTestContext { - services.AddScoped, SoftDeletionResourceDefinition>(); - services.AddScoped, SoftDeletionResourceDefinition>(); + services.AddResourceDefinition>(); + services.AddResourceDefinition>(); }); } diff --git a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index faefa5c79b..5430b34ae3 100644 --- a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -170,6 +170,34 @@ public void AddResourceRepository_Registers_All_LongForm_Repository_Interfaces() Assert.IsType(provider.GetRequiredService(typeof(IResourceWriteRepository))); } + [Fact] + public void AddResourceDefinition_Registers_Shorthand_Definition_Interface() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddResourceDefinition(); + + // Assert + ServiceProvider provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService(typeof(IResourceDefinition))); + } + + [Fact] + public void AddResourceDefinition_Registers_LongForm_Definition_Interface() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddResourceDefinition(); + + // Assert + ServiceProvider provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService(typeof(IResourceDefinition))); + } + [Fact] public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified() { @@ -421,6 +449,84 @@ public Task RemoveFromToManyRelationshipAsync(GuidResource primaryResource, ISet } } + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class IntResourceDefinition : IResourceDefinition + { + public IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) + { + throw new NotImplementedException(); + } + + public FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + throw new NotImplementedException(); + } + + public SortExpression OnApplySort(SortExpression existingSort) + { + throw new NotImplementedException(); + } + + public PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + { + throw new NotImplementedException(); + } + + public SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + throw new NotImplementedException(); + } + + public QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + throw new NotImplementedException(); + } + + public IDictionary GetMeta(IntResource resource) + { + throw new NotImplementedException(); + } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class GuidResourceDefinition : IResourceDefinition + { + public IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) + { + throw new NotImplementedException(); + } + + public FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + throw new NotImplementedException(); + } + + public SortExpression OnApplySort(SortExpression existingSort) + { + throw new NotImplementedException(); + } + + public PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + { + throw new NotImplementedException(); + } + + public SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + throw new NotImplementedException(); + } + + public QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + throw new NotImplementedException(); + } + + public IDictionary GetMeta(GuidResource resource) + { + throw new NotImplementedException(); + } + } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class TestContext : DbContext { From 940db8ceb19c66ecab13131809924b60ea5eec7f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 14 Apr 2021 13:53:36 +0200 Subject: [PATCH 02/22] Basic wire-up for write callbacks --- .../Queries/IQueryLayerComposer.cs | 9 ++ .../Queries/Internal/QueryLayerComposer.cs | 6 + .../EntityFrameworkCoreRepository.cs | 9 ++ .../Resources/IResourceDefinition.cs | 104 +++++++++++++++ .../Resources/IResourceDefinitionAccessor.cs | 50 ++++++++ .../Resources/IResourceFactory.cs | 8 ++ .../Resources/JsonApiResourceDefinition.cs | 50 ++++++++ .../Resources/ResourceDefinitionAccessor.cs | 78 ++++++++++++ .../Resources/ResourceFactory.cs | 6 + .../Services/JsonApiResourceService.cs | 17 +++ .../ServiceCollectionExtensionsTests.cs | 80 ++++++++++++ .../ResourceHooks/HooksTestsSetup.cs | 118 +++++++++++++++++- 12 files changed, 532 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index c3fa8428e4..35094f0e43 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -1,8 +1,10 @@ +using System; using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.Queries { @@ -57,5 +59,12 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc /// Builds a query for a to-many relationship with a filter to match on its left and right resource IDs. /// QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds); + + /// + /// Provides access to the request-scoped instance. This method has been added solely to prevent introducing a + /// breaking change in the constructor and will be removed in the next major version. + /// + [Obsolete] + IResourceDefinitionAccessor GetResourceDefinitionAccessor(); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index ffaab66790..58fc650321 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -414,6 +414,12 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, T }; } + /// + public IResourceDefinitionAccessor GetResourceDefinitionAccessor() + { + return _resourceDefinitionAccessor; + } + protected virtual IReadOnlyCollection GetIncludeElements(IReadOnlyCollection includeElements, ResourceContext resourceContext) { diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 36afb6dd7c..a8782ecd85 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -35,6 +35,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; private readonly TraceLogWriter> _traceWriter; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; /// public virtual string TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); @@ -55,6 +56,10 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextR _constraintProviders = constraintProviders; _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); + +#pragma warning disable 612 // Method is obsolete + _resourceDefinitionAccessor = resourceFactory.GetResourceDefinitionAccessor(); +#pragma warning restore 612 } /// @@ -175,6 +180,8 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); } + await _resourceDefinitionAccessor.OnBeforeCreateResourceAsync(resourceForDatabase, cancellationToken); + DbSet dbSet = _dbContext.Set(); await dbSet.AddAsync(resourceForDatabase, cancellationToken); @@ -216,6 +223,8 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); } + await _resourceDefinitionAccessor.OnBeforeUpdateResourceAsync(resourceFromDatabase, cancellationToken); + await SaveChangesAsync(cancellationToken); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 69cf85639d..d8a39ae610 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Expressions; @@ -29,6 +31,7 @@ public interface IResourceDefinition : IResourceDefinition [PublicAPI] + // ReSharper disable once TypeParameterCanBeVariant -- Justification: making TId contravariant is a breaking change. public interface IResourceDefinition where TResource : class, IIdentifiable { @@ -129,5 +132,106 @@ public interface IResourceDefinition /// Enables to add JSON:API meta information, specific to this resource. /// IDictionary GetMeta(TResource resource); + + /// + /// Enables to execute custom logic to initialize a newly instantiated resource during a POST request. This is typically used to assign default values to + /// properties or to side-load-and-attach required relationships. + /// + /// + /// A freshly instantiated resource object. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Enables to execute custom logic, just before a resource is inserted in the underlying data store, during a POST request. This is typically used to + /// overwrite attributes from the incoming request, such as a creation-timestamp. Another use case is to add a notification message to an outbox table, + /// which gets committed along with the resource write in a single transaction (see https://microservices.io/patterns/data/transactional-outbox.html). + /// + /// + /// The resource with incoming request data applied on it. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Enables to execute custom logic after a resource has been inserted in the underlying data store, during a POST request. A typical use case is to + /// enqueue a notification message on a service bus. + /// + /// + /// The re-fetched resource after a successful insertion. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Enables to execute custom logic to validate if the update request can be processed, based on the currently stored resource. A typical use case is to + /// throw when the resource is soft-deleted or archived. + /// + /// + /// The resource as currently stored in the underlying data store. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Enables to execute custom logic, just before a resource is updated in the underlying data store, during a PATCH request. This is typically used to + /// overwrite attributes from the incoming request, such as a last-modification-timestamp. Another use case is to add a notification message to an outbox + /// table, which gets committed along with the resource write in a single transaction (see + /// https://microservices.io/patterns/data/transactional-outbox.html). + /// + /// + /// The stored resource with incoming request data applied on it. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Enables to execute custom logic after a resource has been updated in the underlying data store, during a PATCH request. A typical use case is to + /// enqueue a notification message on a service bus. + /// + /// + /// The re-fetched resource after a successful update. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Enables to execute custom logic, just before a resource is deleted from the underlying data store, during a DELETE request. This enables to throw in + /// case the user does not have permission, an attempt is made to delete an unarchived resource or a non-closed work item etc. Another use case is to add + /// a notification message to an outbox table, which gets committed along with the resource write in a single transaction (see + /// https://microservices.io/patterns/data/transactional-outbox.html). + /// + /// + /// The identifier of the resource to delete. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken); + + /// + /// Enables to execute custom logic after a resource has been deleted from the underlying data store, during a DELETE request. A typical use case is to + /// enqueue a notification message on a service bus. + /// + /// + /// The identifier of the resource to delete. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 90910c8d5b..4be68bc4e2 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Resources @@ -45,5 +47,53 @@ public interface IResourceDefinitionAccessor /// Invokes for the specified resource. /// IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance); + + /// + /// Invokes for the specified resource. + /// + Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource ID. + /// + Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource ID. + /// + Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 38a25ad996..cd520b4f6f 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -1,5 +1,6 @@ using System; using System.Linq.Expressions; +using JsonApiDotNetCore.Repositories; namespace JsonApiDotNetCore.Resources { @@ -23,5 +24,12 @@ public TResource CreateInstance() /// Returns an expression tree that represents creating a new resource object instance. /// public NewExpression CreateNewExpression(Type resourceType); + + /// + /// Provides access to the request-scoped instance. This method has been added solely to prevent introducing a + /// breaking change in the constructor and will be removed in the next major version. + /// + [Obsolete] + IResourceDefinitionAccessor GetResourceDefinitionAccessor(); } } diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index 06987f123a..d679f461a3 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -3,6 +3,8 @@ using System.ComponentModel; using System.Linq; using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; @@ -113,6 +115,54 @@ public virtual IDictionary GetMeta(TResource resource) return null; } + /// + public virtual Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + /// /// This is an alias type intended to simplify the implementation's method signature. See for usage /// details. diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 9553d7fdfb..00a88e1759 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; @@ -89,6 +91,82 @@ public IDictionary GetMeta(Type resourceType, IIdentifiable reso return resourceDefinition.GetMeta((dynamic)resourceInstance); } + /// + public async Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnInitializeResourceAsync(resource, cancellationToken); + } + + /// + public async Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnBeforeCreateResourceAsync(resource, cancellationToken); + } + + /// + public async Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnAfterCreateResourceAsync(resource, cancellationToken); + } + + /// + public async Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnAfterGetForUpdateResourceAsync(resource, cancellationToken); + } + + /// + public async Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnBeforeUpdateResourceAsync(resource, cancellationToken); + } + + /// + public async Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnAfterUpdateResourceAsync(resource, cancellationToken); + } + + /// + public async Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnBeforeDeleteResourceAsync(id, cancellationToken); + } + + /// + public async Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnAfterDeleteResourceAsync(id, cancellationToken); + } + protected virtual object ResolveResourceDefinition(Type resourceType) { ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 4b98b8e985..721022d6f5 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -19,6 +19,12 @@ public ResourceFactory(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } + /// + public IResourceDefinitionAccessor GetResourceDefinitionAccessor() + { + return _serviceProvider.GetRequiredService(); + } + /// public IIdentifiable CreateInstance(Type resourceType) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index d75f6737bd..a0bebc273d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -34,6 +34,7 @@ public class JsonApiResourceService : IResourceService _resourceChangeTracker; private readonly IResourceHookExecutorFacade _hookExecutor; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, @@ -56,6 +57,10 @@ public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQ _resourceChangeTracker = resourceChangeTracker; _hookExecutor = hookExecutor; _traceWriter = new TraceLogWriter>(loggerFactory); + +#pragma warning disable 612 // Method is obsolete + _resourceDefinitionAccessor = queryLayerComposer.GetResourceDefinitionAccessor(); +#pragma warning restore 612 } /// @@ -197,6 +202,8 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); + await _resourceDefinitionAccessor.OnInitializeResourceAsync(resourceForDatabase, cancellationToken); + try { await _repositoryAccessor.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); @@ -223,6 +230,8 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio AssertPrimaryResourceExists(resourceFromDatabase); + await _resourceDefinitionAccessor.OnAfterCreateResourceAsync(resourceFromDatabase, cancellationToken); + _hookExecutor.AfterCreate(resourceFromDatabase); _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); @@ -364,6 +373,8 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); + await _resourceDefinitionAccessor.OnAfterGetForUpdateResourceAsync(resourceFromDatabase, cancellationToken); + try { await _repositoryAccessor.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); @@ -377,6 +388,8 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can TResource afterResourceFromDatabase = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); AssertPrimaryResourceExists(afterResourceFromDatabase); + await _resourceDefinitionAccessor.OnAfterUpdateResourceAsync(afterResourceFromDatabase, cancellationToken); + _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); @@ -433,6 +446,8 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke _hookExecutor.BeforeDelete(id); + await _resourceDefinitionAccessor.OnBeforeDeleteResourceAsync(id, cancellationToken); + try { await _repositoryAccessor.DeleteAsync(id, cancellationToken); @@ -444,6 +459,8 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke throw; } + await _resourceDefinitionAccessor.OnAfterDeleteResourceAsync(id, cancellationToken); + _hookExecutor.AfterDelete(id); } diff --git a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index 5430b34ae3..044f9b0634 100644 --- a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -486,6 +486,46 @@ public IDictionary GetMeta(IntResource resource) { throw new NotImplementedException(); } + + public Task OnInitializeResourceAsync(IntResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnBeforeCreateResourceAsync(IntResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAfterCreateResourceAsync(IntResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAfterGetForUpdateResourceAsync(IntResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnBeforeUpdateResourceAsync(IntResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAfterUpdateResourceAsync(IntResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnBeforeDeleteResourceAsync(int id, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAfterDeleteResourceAsync(int id, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] @@ -525,6 +565,46 @@ public IDictionary GetMeta(GuidResource resource) { throw new NotImplementedException(); } + + public Task OnInitializeResourceAsync(GuidResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnBeforeCreateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAfterCreateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAfterGetForUpdateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnBeforeUpdateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAfterUpdateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnBeforeDeleteResourceAsync(Guid id, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAfterDeleteResourceAsync(Guid id, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } } [UsedImplicitly(ImplicitUseTargetFlags.Members)] diff --git a/test/UnitTests/ResourceHooks/HooksTestsSetup.cs b/test/UnitTests/ResourceHooks/HooksTestsSetup.cs index 018ece0963..a4c92edb20 100644 --- a/test/UnitTests/ResourceHooks/HooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/HooksTestsSetup.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Hooks.Internal; @@ -15,7 +18,6 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -218,8 +220,7 @@ private void SetupProcessorFactoryForResourceDefinition(Mock CreateTestRepository(AppDbContext dbContext, IResourceGraph resourceGraph) where TModel : class, IIdentifiable { - IServiceProvider serviceProvider = ((IInfrastructure)dbContext).Instance; - var resourceFactory = new ResourceFactory(serviceProvider); + var resourceFactory = new TestResourceFactory(); IDbContextResolver resolver = CreateTestDbResolver(dbContext); var targetedFields = new TargetedFields(); @@ -406,5 +407,116 @@ public void Deconstruct(out Mock> constrai secondSecondaryResourceContainerMock = SecondSecondaryResourceContainerMock; } } + + private sealed class TestResourceFactory : IResourceFactory + { + public IIdentifiable CreateInstance(Type resourceType) + { + return (IIdentifiable)Activator.CreateInstance(resourceType); + } + + public TResource CreateInstance() + where TResource : IIdentifiable + { + return (TResource)Activator.CreateInstance(typeof(TResource)); + } + + public NewExpression CreateNewExpression(Type resourceType) + { + return Expression.New(resourceType); + } + + public IResourceDefinitionAccessor GetResourceDefinitionAccessor() + { + return new NeverResourceDefinitionAccessor(); + } + + private sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor + { + public IReadOnlyCollection OnApplyIncludes(Type resourceType, + IReadOnlyCollection existingIncludes) + { + return existingIncludes; + } + + public FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter) + { + return existingFilter; + } + + public SortExpression OnApplySort(Type resourceType, SortExpression existingSort) + { + return existingSort; + } + + public PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination) + { + return existingPagination; + } + + public SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet) + { + return existingSparseFieldSet; + } + + public object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName) + { + return new QueryStringParameterHandlers(); + } + + public IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance) + { + return null; + } + + public Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + } + } } } From 89da7e3d4ae88a66a5e3253671fdcbae184e5a3d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 14 Apr 2021 14:15:53 +0200 Subject: [PATCH 03/22] Added tests for archiving --- JsonApiDotNetCore.sln.DotSettings | 1 + .../Archiving/ArchiveTests.cs | 612 ++++++++++++++++++ .../Archiving/BroadcastComment.cs | 20 + .../Archiving/BroadcastCommentsController.cs | 15 + .../Archiving/TelevisionBroadcast.cs | 27 + .../TelevisionBroadcastDefinition.cs | 207 ++++++ .../TelevisionBroadcastsController.cs | 15 + .../Archiving/TelevisionDbContext.cs | 19 + .../Archiving/TelevisionFakers.cs | 40 ++ .../Archiving/TelevisionNetwork.cs | 17 + .../Archiving/TelevisionNetworksController.cs | 15 + .../Archiving/TelevisionStation.cs | 17 + .../Archiving/TelevisionStationsController.cs | 15 + 13 files changed, 1020 insertions(+) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index a32ac146aa..54dbf2a2ec 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -626,4 +626,5 @@ $left$ = $right$; True True True + True diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs new file mode 100644 index 0000000000..3d9876aeaf --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -0,0 +1,612 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class ArchiveTests : IClassFixture, TelevisionDbContext>> + { + private readonly ExampleIntegrationTestContext, TelevisionDbContext> _testContext; + private readonly TelevisionFakers _fakers = new TelevisionFakers(); + + public ArchiveTests(ExampleIntegrationTestContext, TelevisionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); + } + + [Fact] + public async Task Can_get_archived_resource_by_ID() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(broadcast.StringId); + responseDocument.SingleData.Attributes["archivedAt"].Should().BeCloseTo(broadcast.ArchivedAt); + } + + [Fact] + public async Task Can_get_unarchived_resource_by_ID() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + broadcast.ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(broadcast.StringId); + responseDocument.SingleData.Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_primary_resources_excludes_archived() + { + // Arrange + List broadcasts = _fakers.TelevisionBroadcast.Generate(2); + broadcasts[1].ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Broadcasts.AddRange(broadcasts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/televisionBroadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(broadcasts[1].StringId); + responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_primary_resources_with_filter_includes_archived() + { + // Arrange + List broadcasts = _fakers.TelevisionBroadcast.Generate(2); + broadcasts[1].ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Broadcasts.AddRange(broadcasts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/televisionBroadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(broadcasts[0].StringId); + responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeCloseTo(broadcasts[0].ArchivedAt); + responseDocument.ManyData[1].Id.Should().Be(broadcasts[1].StringId); + responseDocument.ManyData[1].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_primary_resource_by_ID_with_include_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}?include=broadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(station.StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.Included[0].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_primary_resource_by_ID_with_include_and_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = + $"/televisionStations/{station.StringId}?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(station.StringId); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.Included[0].Attributes["archivedAt"].Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt); + responseDocument.Included[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_secondary_resource_includes_archived() + { + // Arrange + BroadcastComment comment = _fakers.BroadcastComment.Generate(); + comment.AppliesTo = _fakers.TelevisionBroadcast.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Comments.Add(comment); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/broadcastComments/{comment.StringId}/appliesTo"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(comment.AppliesTo.StringId); + responseDocument.SingleData.Attributes["archivedAt"].Should().BeCloseTo(comment.AppliesTo.ArchivedAt); + } + + [Fact] + public async Task Get_secondary_resources_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}/broadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_secondary_resources_with_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt); + responseDocument.ManyData[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.ManyData[1].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_secondary_resource_by_ID_with_include_excludes_archived() + { + // Arrange + TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); + network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Networks.Add(network); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionNetworks/{network.StringId}/stations?include=broadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); + responseDocument.Included[0].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_secondary_resource_by_ID_with_include_and_filter_includes_archived() + { + TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); + network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Networks.Add(network); + await dbContext.SaveChangesAsync(); + }); + + string route = + $"/televisionNetworks/{network.StringId}/stations?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).StringId); + responseDocument.Included[0].Attributes["archivedAt"].Should().BeCloseTo(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt); + responseDocument.Included[1].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); + responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Can_create_unarchived_resource() + { + // Arrange + TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + attributes = new + { + title = newBroadcast.Title, + airedAt = newBroadcast.AiredAt + } + } + }; + + const string route = "/televisionBroadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["title"].Should().Be(newBroadcast.Title); + responseDocument.SingleData.Attributes["airedAt"].Should().BeCloseTo(newBroadcast.AiredAt); + responseDocument.SingleData.Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Cannot_create_archived_resource() + { + // Arrange + TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + attributes = new + { + title = newBroadcast.Title, + airedAt = newBroadcast.AiredAt, + archivedAt = newBroadcast.ArchivedAt + } + } + }; + + const string route = "/televisionBroadcasts"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Television broadcasts cannot be created in archived state."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_archive_resource() + { + // Arrange + TelevisionBroadcast existingBroadcast = _fakers.TelevisionBroadcast.Generate(); + existingBroadcast.ArchivedAt = null; + + DateTimeOffset newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt!.Value; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(existingBroadcast); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + id = existingBroadcast.StringId, + attributes = new + { + archivedAt = newArchivedAt + } + } + }; + + string route = "/televisionBroadcasts/" + existingBroadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(existingBroadcast.Id); + + broadcastInDatabase.ArchivedAt.Should().BeCloseTo(newArchivedAt); + }); + } + + [Fact] + public async Task Can_unarchive_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + id = broadcast.StringId, + attributes = new + { + archivedAt = (DateTimeOffset?)null + } + } + }; + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id); + + broadcastInDatabase.ArchivedAt.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_shift_archive_date() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + + DateTimeOffset? newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + id = broadcast.StringId, + attributes = new + { + archivedAt = newArchivedAt + } + } + }; + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Archive date of television broadcasts cannot be shifted. Unarchive it first."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_delete_archived_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); + + broadcastInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_unarchived_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + broadcast.ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Television broadcasts must first be archived before they can be deleted."); + error.Detail.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs new file mode 100644 index 0000000000..d0d244681e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs @@ -0,0 +1,20 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class BroadcastComment : Identifiable + { + [Attr] + public string Text { get; set; } + + [Attr] + public DateTimeOffset CreatedAt { get; set; } + + [HasOne] + public TelevisionBroadcast AppliesTo { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs new file mode 100644 index 0000000000..e6d5e3d1b6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class BroadcastCommentsController : JsonApiController + { + public BroadcastCommentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs new file mode 100644 index 0000000000..97e3c3f4be --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TelevisionBroadcast : Identifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public DateTimeOffset AiredAt { get; set; } + + [Attr] + public DateTimeOffset? ArchivedAt { get; set; } + + [HasOne] + public TelevisionStation AiredOn { get; set; } + + [HasMany] + public ISet Comments { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs new file mode 100644 index 0000000000..7ee460e2de --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition + { + private readonly TelevisionDbContext _dbContext; + private readonly IJsonApiRequest _request; + private readonly IEnumerable _constraintProviders; + private readonly ResourceContext _broadcastContext; + + private DateTimeOffset? _storedArchivedAt; + + public TelevisionBroadcastDefinition(IResourceGraph resourceGraph, TelevisionDbContext dbContext, IJsonApiRequest request, + IEnumerable constraintProviders) + : base(resourceGraph) + { + _dbContext = dbContext; + _request = request; + _constraintProviders = constraintProviders; + _broadcastContext = resourceGraph.GetResourceContext(); + } + + public override FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + if (_request.IsReadOnly) + { + // Rule: hide archived broadcasts in collections, unless a filter is specified. + + if (IsReturningCollectionOfTelevisionBroadcasts() && !HasFilterOnArchivedAt(existingFilter)) + { + AttrAttribute archivedAtAttribute = + _broadcastContext.Attributes.Single(attr => attr.Property.Name == nameof(TelevisionBroadcast.ArchivedAt)); + + var archivedAtChain = new ResourceFieldChainExpression(archivedAtAttribute); + + FilterExpression isUnarchived = new ComparisonExpression(ComparisonOperator.Equals, archivedAtChain, new NullConstantExpression()); + + return existingFilter == null + ? isUnarchived + : new LogicalExpression(LogicalOperator.And, ArrayFactory.Create(existingFilter, isUnarchived)); + } + } + + return base.OnApplyFilter(existingFilter); + } + + private bool IsReturningCollectionOfTelevisionBroadcasts() + { + return IsRequestingCollectionOfTelevisionBroadcasts() || IsIncludingCollectionOfTelevisionBroadcasts(); + } + + private bool IsRequestingCollectionOfTelevisionBroadcasts() + { + if (_request.IsCollection) + { + if (_request.PrimaryResource == _broadcastContext || _request.SecondaryResource == _broadcastContext) + { + return true; + } + } + + return false; + } + + private bool IsIncludingCollectionOfTelevisionBroadcasts() + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + IncludeElementExpression[] includeElements = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .SelectMany(include => include.Elements) + .ToArray(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + foreach (IncludeElementExpression includeElement in includeElements) + { + if (includeElement.Relationship is HasManyAttribute && includeElement.Relationship.RightType == _broadcastContext.ResourceType) + { + return true; + } + } + + return false; + } + + private bool HasFilterOnArchivedAt(FilterExpression existingFilter) + { + if (existingFilter == null) + { + return false; + } + + var walker = new FilterWalker(); + walker.Visit(existingFilter, null); + + return walker.HasFilterOnArchivedAt; + } + + public override Task OnBeforeCreateResourceAsync(TelevisionBroadcast broadcast, CancellationToken cancellationToken) + { + AssertIsNotArchived(broadcast); + + return base.OnBeforeCreateResourceAsync(broadcast, cancellationToken); + } + + [AssertionMethod] + private static void AssertIsNotArchived(TelevisionBroadcast broadcast) + { + if (broadcast.ArchivedAt != null) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "Television broadcasts cannot be created in archived state." + }); + } + } + + public override Task OnAfterGetForUpdateResourceAsync(TelevisionBroadcast resource, CancellationToken cancellationToken) + { + _storedArchivedAt = resource.ArchivedAt; + + return base.OnAfterGetForUpdateResourceAsync(resource, cancellationToken); + } + + public override Task OnBeforeUpdateResourceAsync(TelevisionBroadcast resource, CancellationToken cancellationToken) + { + AssertIsNotShiftingArchiveDate(resource); + + return base.OnBeforeUpdateResourceAsync(resource, cancellationToken); + } + + [AssertionMethod] + private void AssertIsNotShiftingArchiveDate(TelevisionBroadcast resource) + { + if (_storedArchivedAt != null && resource.ArchivedAt != null && _storedArchivedAt != resource.ArchivedAt) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "Archive date of television broadcasts cannot be shifted. Unarchive it first." + }); + } + } + + public override async Task OnBeforeDeleteResourceAsync(int broadcastId, CancellationToken cancellationToken) + { + TelevisionBroadcast televisionBroadcast = + await _dbContext.Broadcasts.FirstOrDefaultAsync(broadcast => broadcast.Id == broadcastId, cancellationToken); + + if (televisionBroadcast != null) + { + AssertIsArchived(televisionBroadcast); + } + + await base.OnBeforeDeleteResourceAsync(broadcastId, cancellationToken); + } + + [AssertionMethod] + private static void AssertIsArchived(TelevisionBroadcast televisionBroadcast) + { + if (televisionBroadcast.ArchivedAt == null) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "Television broadcasts must first be archived before they can be deleted." + }); + } + } + + private sealed class FilterWalker : QueryExpressionRewriter + { + public bool HasFilterOnArchivedAt { get; private set; } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object argument) + { + if (expression.Fields.First().Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) + { + HasFilterOnArchivedAt = true; + } + + return base.VisitResourceFieldChain(expression, argument); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs new file mode 100644 index 0000000000..cc6575523c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class TelevisionBroadcastsController : JsonApiController + { + public TelevisionBroadcastsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs new file mode 100644 index 0000000000..8bc71dea0d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TelevisionDbContext : DbContext + { + public DbSet Networks { get; set; } + public DbSet Stations { get; set; } + public DbSet Broadcasts { get; set; } + public DbSet Comments { get; set; } + + public TelevisionDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs new file mode 100644 index 0000000000..288c75bdbf --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs @@ -0,0 +1,40 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + internal sealed class TelevisionFakers : FakerContainer + { + private readonly Lazy> _lazyTelevisionNetworkFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(network => network.Name, faker => faker.Company.CompanyName())); + + private readonly Lazy> _lazyTelevisionStationFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(station => station.Name, faker => faker.Company.CompanyName())); + + private readonly Lazy> _lazyTelevisionBroadcastFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(broadcast => broadcast.Title, faker => faker.Lorem.Sentence()) + .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset()) + .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset())); + + private readonly Lazy> _lazyBroadcastCommentFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) + .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset())); + + public Faker TelevisionNetwork => _lazyTelevisionNetworkFaker.Value; + public Faker TelevisionStation => _lazyTelevisionStationFaker.Value; + public Faker TelevisionBroadcast => _lazyTelevisionBroadcastFaker.Value; + public Faker BroadcastComment => _lazyBroadcastCommentFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs new file mode 100644 index 0000000000..3d5c213d31 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TelevisionNetwork : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public ISet Stations { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs new file mode 100644 index 0000000000..f89eef1f92 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class TelevisionNetworksController : JsonApiController + { + public TelevisionNetworksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs new file mode 100644 index 0000000000..8087ceff75 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TelevisionStation : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public ISet Broadcasts { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs new file mode 100644 index 0000000000..490778b789 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class TelevisionStationsController : JsonApiController + { + public TelevisionStationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} From 1438db2d5ef5b5f6792a3c2495c9c80d3da2f281 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Apr 2021 17:39:30 +0200 Subject: [PATCH 04/22] Cleanup --- .../IntegrationTests/IdObfuscation/IdObfuscationTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index b941ff28f4..f6703e7272 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -470,7 +470,6 @@ public async Task Cannot_delete_missing_resource() error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'bankAccounts' with ID '{stringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); } } } From ae2c63a189b0a0b8ee463a4ca885a84504d19202 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Apr 2021 17:49:44 +0200 Subject: [PATCH 05/22] Improved support for soft-deletion --- .../Services/JsonApiResourceService.cs | 65 +- .../IntegrationTests/SoftDeletion/Company.cs | 4 +- .../SoftDeletion/Department.cs | 4 +- .../SoftDeletion/ISoftDeletable.cs | 6 +- .../SoftDeletionAwareResourceService.cs | 129 +++ .../SoftDeletion/SoftDeletionDbContext.cs | 11 + .../SoftDeletion/SoftDeletionFakers.cs | 25 + .../SoftDeletionResourceDefinition.cs | 38 - .../SoftDeletion/SoftDeletionTests.cs | 935 ++++++++++++++---- 9 files changed, 977 insertions(+), 240 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionFakers.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionResourceDefinition.cs diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index a0bebc273d..631f2720d5 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -103,8 +103,7 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetSingle); - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); - AssertPrimaryResourceExists(primaryResource); + TResource primaryResource = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle); _hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle); @@ -225,10 +224,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio throw; } - TResource resourceFromDatabase = - await TryGetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); - - AssertPrimaryResourceExists(resourceFromDatabase); + TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); await _resourceDefinitionAccessor.OnAfterCreateResourceAsync(resourceFromDatabase, cancellationToken); @@ -247,13 +243,14 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio return resourceFromDatabase; } - private async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource resource, CancellationToken cancellationToken) + protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) { var missingResources = new List(); - foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(resource)) + foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds( + primaryResource)) { - object rightValue = relationship.GetValue(resource); + object rightValue = relationship.GetValue(primaryResource); ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); IAsyncEnumerable missingResourcesInRelationship = @@ -287,7 +284,7 @@ private async IAsyncEnumerable GetMissingRightRes } /// - public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + public virtual async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -316,10 +313,8 @@ public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshi } catch (DataStoreUpdateException) { - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); - AssertPrimaryResourceExists(primaryResource); - - await AssertResourcesExistAsync(secondaryResourceIds, cancellationToken); + _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); throw; } } @@ -340,16 +335,22 @@ private async Task RemoveExistingIdsFromSecondarySetAsync(TId primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) + protected async Task AssertRightResourcesExistAsync(object rightResourceIds, CancellationToken cancellationToken) { - QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, secondaryResourceIds); + ICollection secondaryResourceIds = _collectionConverter.ExtractResources(rightResourceIds); - List missingResources = - await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, secondaryResourceIds, cancellationToken).ToListAsync(cancellationToken); - - if (missingResources.Any()) + if (secondaryResourceIds.Any()) { - throw new ResourcesInRelationshipsNotFoundException(missingResources); + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, secondaryResourceIds); + + List missingResources = + await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, secondaryResourceIds, cancellationToken) + .ToListAsync(cancellationToken); + + if (missingResources.Any()) + { + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } } } @@ -385,8 +386,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can throw; } - TResource afterResourceFromDatabase = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); - AssertPrimaryResourceExists(afterResourceFromDatabase); + TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); await _resourceDefinitionAccessor.OnAfterUpdateResourceAsync(afterResourceFromDatabase, cancellationToken); @@ -429,7 +429,7 @@ public virtual async Task SetRelationshipAsync(TId primaryId, string relationshi } catch (DataStoreUpdateException) { - await AssertResourcesExistAsync(_collectionConverter.ExtractResources(secondaryResourceIds), cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); throw; } @@ -454,8 +454,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke } catch (DataStoreUpdateException) { - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - AssertPrimaryResourceExists(primaryResource); + _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); throw; } @@ -465,7 +464,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke } /// - public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + public virtual async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -481,7 +480,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relati AssertHasRelationship(_request.Relationship, relationshipName); TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId, cancellationToken); - await AssertResourcesExistAsync(secondaryResourceIds, cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); if (secondaryResourceIds.Any()) { @@ -489,6 +488,14 @@ public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relati } } + protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + { + TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, fieldSelection, cancellationToken); + AssertPrimaryResourceExists(primaryResource); + + return primaryResource; + } + private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResource, fieldSelection); @@ -497,7 +504,7 @@ private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSel return primaryResources.SingleOrDefault(); } - private async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) + protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) { QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs index 1e4dad6f61..c2cd8b7b45 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; @@ -11,8 +12,7 @@ public sealed class Company : Identifiable, ISoftDeletable [Attr] public string Name { get; set; } - [Attr] - public bool IsSoftDeleted { get; set; } + public DateTimeOffset? SoftDeletedAt { get; set; } [HasMany] public ICollection Departments { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs index db2ec47c68..3065f461a9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs @@ -1,3 +1,4 @@ +using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -10,8 +11,7 @@ public sealed class Department : Identifiable, ISoftDeletable [Attr] public string Name { get; set; } - [Attr] - public bool IsSoftDeleted { get; set; } + public DateTimeOffset? SoftDeletedAt { get; set; } [HasOne] public Company Company { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/ISoftDeletable.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/ISoftDeletable.cs index 8aa1b29847..69fbbc5b45 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/ISoftDeletable.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/ISoftDeletable.cs @@ -1,7 +1,11 @@ +using System; +using JetBrains.Annotations; + namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion { + [UsedImplicitly(ImplicitUseTargetFlags.Members)] public interface ISoftDeletable { - bool IsSoftDeleted { get; set; } + DateTimeOffset? SoftDeletedAt { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs new file mode 100644 index 0000000000..74a7e9c8b6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class SoftDeletionAwareResourceService : JsonApiResourceService + where TResource : class, IIdentifiable + { + private readonly ISystemClock _systemClock; + private readonly ITargetedFields _targetedFields; + private readonly IResourceRepositoryAccessor _repositoryAccessor; + private readonly IJsonApiRequest _request; + + public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, IResourceRepositoryAccessor repositoryAccessor, + IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor) + { + _systemClock = systemClock; + _targetedFields = targetedFields; + _repositoryAccessor = repositoryAccessor; + _request = request; + } + + // To optimize performance, the default resource service does not always fetch all resources on write operations. + // We do that here, to assure a 404 error is thrown for soft-deleted resources. + + public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType))) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + } + + return await base.CreateAsync(resource, cancellationToken); + } + + public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + { + if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType))) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + } + + return await base.UpdateAsync(id, resource, cancellationToken); + } + + public override async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds, + CancellationToken cancellationToken) + { + if (IsSoftDeletable(_request.Relationship.RightType)) + { + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + } + + await base.SetRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + public override async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + if (IsSoftDeletable(typeof(TResource))) + { + _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + } + + if (IsSoftDeletable(_request.Relationship.RightType)) + { + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + } + + await base.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + if (IsSoftDeletable(typeof(TResource))) + { + await SoftDeleteAsync(id, cancellationToken); + } + else + { + await base.DeleteAsync(id, cancellationToken); + } + } + + private async Task SoftDeleteAsync(TId id, CancellationToken cancellationToken) + { + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + + ((ISoftDeletable)resourceFromDatabase).SoftDeletedAt = _systemClock.UtcNow; + + // A delete operation does not target any fields, so we can just pass resourceFromDatabase twice. + await _repositoryAccessor.UpdateAsync(resourceFromDatabase, resourceFromDatabase, cancellationToken); + } + + private static bool IsSoftDeletable(Type resourceType) + { + return typeof(ISoftDeletable).IsAssignableFrom(resourceType); + } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class SoftDeletionAwareResourceService : SoftDeletionAwareResourceService, IResourceService + where TResource : class, IIdentifiable + { + public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, IResourceRepositoryAccessor repositoryAccessor, + IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) + : base(systemClock, targetedFields, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, hookExecutor) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs index 17e3207f61..d46e2d3605 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs @@ -1,6 +1,8 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +// @formatter:wrap_chained_method_calls chop_always + namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion { [UsedImplicitly(ImplicitUseTargetFlags.Members)] @@ -13,5 +15,14 @@ public SoftDeletionDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasQueryFilter(company => company.SoftDeletedAt == null); + + builder.Entity() + .HasQueryFilter(department => department.SoftDeletedAt == null); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionFakers.cs new file mode 100644 index 0000000000..d3f6c1ecec --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionFakers.cs @@ -0,0 +1,25 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + internal sealed class SoftDeletionFakers : FakerContainer + { + private readonly Lazy> _lazyCompanyFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(company => company.Name, faker => faker.Company.CompanyName())); + + private readonly Lazy> _lazyDepartmentFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(department => department.Name, faker => faker.Commerce.Department())); + + public Faker Company => _lazyCompanyFaker.Value; + public Faker Department => _lazyDepartmentFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionResourceDefinition.cs deleted file mode 100644 index e23b650ac6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionResourceDefinition.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class SoftDeletionResourceDefinition : JsonApiResourceDefinition - where TResource : class, IIdentifiable, ISoftDeletable - { - private readonly IResourceGraph _resourceGraph; - - public SoftDeletionResourceDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - _resourceGraph = resourceGraph; - } - - public override FilterExpression OnApplyFilter(FilterExpression existingFilter) - { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(); - - AttrAttribute isSoftDeletedAttribute = - resourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(ISoftDeletable.IsSoftDeleted)); - - var isNotSoftDeleted = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(isSoftDeletedAttribute), - new LiteralConstantExpression("false")); - - return existingFilter == null - ? (FilterExpression)isNotSoftDeleted - : new LogicalExpression(LogicalOperator.And, ArrayFactory.Create(isNotSoftDeleted, existingFilter)); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 9484c7175d..9cb0cd3dbc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -1,12 +1,18 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Common; +using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -14,7 +20,10 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion { public sealed class SoftDeletionTests : IClassFixture, SoftDeletionDbContext>> { + private static readonly DateTimeOffset SoftDeletionTime = 1.January(2001).ToDateTimeOffset(); + private readonly ExampleIntegrationTestContext, SoftDeletionDbContext> _testContext; + private readonly SoftDeletionFakers _fakers = new SoftDeletionFakers(); public SoftDeletionTests(ExampleIntegrationTestContext, SoftDeletionDbContext> testContext) { @@ -25,27 +34,22 @@ public SoftDeletionTests(ExampleIntegrationTestContext { - services.AddResourceDefinition>(); - services.AddResourceDefinition>(); + services.AddSingleton(new FrozenSystemClock + { + UtcNow = 1.January(2005).ToDateTimeOffset() + }); + + services.AddResourceService>(); + services.AddResourceService>(); }); } [Fact] - public async Task Can_get_primary_resources() + public async Task Get_primary_resources_excludes_soft_deleted() { // Arrange - var departments = new List - { - new Department - { - Name = "Sales", - IsSoftDeleted = true - }, - new Department - { - Name = "Marketing" - } - }; + List departments = _fakers.Department.Generate(2); + departments[0].SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -67,25 +71,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_filter_in_primary_resources() + public async Task Filter_on_primary_resources_excludes_soft_deleted() { // Arrange - var departments = new List - { - new Department - { - Name = "Support" - }, - new Department - { - Name = "Sales", - IsSoftDeleted = true - }, - new Department - { - Name = "Marketing" - } - }; + List departments = _fakers.Department.Generate(3); + + departments[0].Name = "Support"; + + departments[1].Name = "Sales"; + departments[1].SoftDeletedAt = SoftDeletionTime; + + departments[2].Name = "Marketing"; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -107,14 +103,47 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_get_deleted_primary_resource_by_ID() + public async Task Get_primary_resources_with_include_excludes_soft_deleted() { // Arrange - var department = new Department + List companies = _fakers.Company.Generate(2); + + companies[0].SoftDeletedAt = SoftDeletionTime; + companies[0].Departments = _fakers.Department.Generate(1); + + companies[1].Departments = _fakers.Department.Generate(2); + companies[1].Departments.ElementAt(1).SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => { - Name = "Sales", - IsSoftDeleted = true - }; + await dbContext.ClearTableAsync(); + dbContext.Companies.AddRange(companies); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/companies?include=departments"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("companies"); + responseDocument.ManyData[0].Id.Should().Be(companies[1].StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("departments"); + responseDocument.Included[0].Id.Should().Be(companies[1].Departments.ElementAt(0).StringId); + } + + [Fact] + public async Task Cannot_get_soft_deleted_primary_resource_by_ID() + { + // Arrange + Department department = _fakers.Department.Generate(); + department.SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -136,28 +165,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'departments' with ID '{department.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); } [Fact] - public async Task Can_get_secondary_resources() + public async Task Cannot_get_secondary_resources_for_soft_deleted_parent() { // Arrange - var company = new Company + Company company = _fakers.Company.Generate(); + company.SoftDeletedAt = SoftDeletionTime; + company.Departments = _fakers.Department.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - Departments = new List - { - new Department - { - Name = "Sales", - IsSoftDeleted = true - }, - new Department - { - Name = "Marketing" - } - } - }; + dbContext.Companies.Add(company); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/companies/{company.StringId}/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'companies' with ID '{company.StringId}' does not exist."); + } + + [Fact] + public async Task Get_secondary_resources_excludes_soft_deleted() + { + // Arrange + Company company = _fakers.Company.Generate(); + company.Departments = _fakers.Department.Generate(2); + company.Departments.ElementAt(0).SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -174,32 +220,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(company.Departments.Skip(1).Single().StringId); + responseDocument.ManyData[0].Id.Should().Be(company.Departments.ElementAt(1).StringId); } [Fact] - public async Task Cannot_get_secondary_resources_for_deleted_parent() + public async Task Cannot_get_secondary_resource_for_soft_deleted_parent() { // Arrange - var company = new Company - { - IsSoftDeleted = true, - Departments = new List - { - new Department - { - Name = "Marketing" - } - } - }; + Department department = _fakers.Department.Generate(); + department.SoftDeletedAt = SoftDeletionTime; + department.Company = _fakers.Company.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Companies.Add(company); + dbContext.Departments.Add(department); await dbContext.SaveChangesAsync(); }); - string route = $"/companies/{company.StringId}/departments"; + string route = $"/departments/{department.StringId}/company"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -212,54 +250,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error 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 'companies' with ID '{company.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); + error.Detail.Should().Be($"Resource of type 'departments' with ID '{department.StringId}' does not exist."); } [Fact] - public async Task Can_get_primary_resources_with_include() + public async Task Cannot_get_soft_deleted_secondary_resource() { // Arrange - var companies = new List - { - new Company - { - Name = "Acme Corporation", - IsSoftDeleted = true, - Departments = new List - { - new Department - { - Name = "Recruitment" - } - } - }, - new Company - { - Name = "AdventureWorks", - Departments = new List - { - new Department - { - Name = "Reception" - }, - new Department - { - Name = "Sales", - IsSoftDeleted = true - } - } - } - }; + Department department = _fakers.Department.Generate(); + department.Company = _fakers.Company.Generate(); + department.Company.SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.Companies.AddRange(companies); + dbContext.Departments.Add(department); await dbContext.SaveChangesAsync(); }); - const string route = "/companies?include=departments"; + string route = $"/departments/{department.StringId}/company"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -267,34 +275,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Type.Should().Be("companies"); - responseDocument.ManyData[0].Id.Should().Be(companies[1].StringId); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("departments"); - responseDocument.Included[0].Id.Should().Be(companies[1].Departments.First().StringId); + responseDocument.Data.Should().BeNull(); } [Fact] - public async Task Can_get_relationship() + public async Task Cannot_get_HasMany_relationship_for_soft_deleted_parent() { // Arrange - var company = new Company + Company company = _fakers.Company.Generate(); + company.SoftDeletedAt = SoftDeletionTime; + company.Departments = _fakers.Department.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - Departments = new List - { - new Department - { - Name = "Sales", - IsSoftDeleted = true - }, - new Department - { - Name = "Marketing" - } - } - }; + dbContext.Companies.Add(company); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/companies/{company.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'companies' with ID '{company.StringId}' does not exist."); + } + + [Fact] + public async Task Get_HasMany_relationship_excludes_soft_deleted() + { + // Arrange + Company company = _fakers.Company.Generate(); + company.Departments = _fakers.Department.Generate(2); + company.Departments.ElementAt(0).SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -311,32 +331,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(company.Departments.Skip(1).Single().StringId); + responseDocument.ManyData[0].Id.Should().Be(company.Departments.ElementAt(1).StringId); } [Fact] - public async Task Cannot_get_relationship_for_deleted_parent() + public async Task Cannot_get_HasOne_relationship_for_soft_deleted_parent() { // Arrange - var company = new Company - { - IsSoftDeleted = true, - Departments = new List - { - new Department - { - Name = "Marketing" - } - } - }; + Department department = _fakers.Department.Generate(); + department.SoftDeletedAt = SoftDeletionTime; + department.Company = _fakers.Company.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Companies.Add(company); + dbContext.Departments.Add(department); await dbContext.SaveChangesAsync(); }); - string route = $"/companies/{company.StringId}/relationships/departments"; + string route = $"/departments/{department.StringId}/relationships/company"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -349,22 +361,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error 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 'companies' with ID '{company.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); + error.Detail.Should().Be($"Resource of type 'departments' with ID '{department.StringId}' does not exist."); } [Fact] - public async Task Cannot_update_deleted_resource() + public async Task Get_HasOne_relationship_excludes_soft_deleted() { // Arrange - var company = new Company + Department department = _fakers.Department.Generate(); + department.Company = _fakers.Company.Generate(); + department.Company.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => { - IsSoftDeleted = true - }; + dbContext.Departments.Add(department); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/departments/{department.StringId}/relationships/company"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task Cannot_create_resource_with_HasMany_relationship_to_soft_deleted() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + string newCompanyName = _fakers.Company.Generate().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Companies.Add(company); + dbContext.Departments.Add(existingDepartment); await dbContext.SaveChangesAsync(); }); @@ -373,18 +409,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "companies", - id = company.StringId, attributes = new { - name = "Umbrella Corporation" + name = newCompanyName + }, + relationships = new + { + departments = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + } } } }; - string route = "/companies/" + company.StringId; + const string route = "/companies"; // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); @@ -393,40 +442,96 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error 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 'companies' with ID '{company.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should() + .Be($"Related resource of type 'departments' with ID '{existingDepartment.StringId}' in relationship 'departments' does not exist."); } [Fact] - public async Task Cannot_update_relationship_for_deleted_parent() + public async Task Cannot_create_resource_with_HasOne_relationship_to_soft_deleted() { // Arrange - var company = new Company + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + string newDepartmentName = _fakers.Department.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new { - IsSoftDeleted = true, - Departments = new List + data = new { - new Department + type = "departments", + attributes = new + { + name = newDepartmentName + }, + relationships = new { - Name = "Marketing" + company = new + { + data = new + { + type = "companies", + id = existingCompany.StringId + } + } } } }; + const string route = "/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'companies' with ID '{existingCompany.StringId}' in relationship 'company' does not exist."); + } + + [Fact] + public async Task Cannot_update_soft_deleted_resource() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + string newCompanyName = _fakers.Company.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Companies.Add(company); + dbContext.Companies.Add(existingCompany); await dbContext.SaveChangesAsync(); }); - string route = $"/companies/{company.StringId}/relationships/departments"; - var requestBody = new { - data = new object[0] + data = new + { + type = "companies", + id = existingCompany.StringId, + attributes = new + { + name = newCompanyName + } + } }; + string route = "/companies/" + existingCompany.StringId; + // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -438,8 +543,502 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error 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 'companies' with ID '{company.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); + error.Detail.Should().Be($"Resource of type 'companies' with ID '{existingCompany.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_resource_with_HasMany_relationship_to_soft_deleted() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingCompany, existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "companies", + id = existingCompany.StringId, + relationships = new + { + departments = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + } + } + } + }; + + string route = "/companies/" + existingCompany.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should() + .Be($"Related resource of type 'departments' with ID '{existingDepartment.StringId}' in relationship 'departments' does not exist."); + } + + [Fact] + public async Task Cannot_update_resource_with_HasOne_relationship_to_soft_deleted() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingDepartment, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "departments", + id = existingDepartment.StringId, + relationships = new + { + company = new + { + data = new + { + type = "companies", + id = existingCompany.StringId + } + } + } + } + }; + + string route = "/departments/" + existingDepartment.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'companies' with ID '{existingCompany.StringId}' in relationship 'company' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasMany_relationship_for_soft_deleted_parent() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + existingCompany.Departments = _fakers.Department.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'companies' with ID '{existingCompany.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasMany_relationship_to_soft_deleted() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingCompany, existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should() + .Be($"Related resource of type 'departments' with ID '{existingDepartment.StringId}' in relationship 'departments' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasOne_relationship_for_soft_deleted_parent() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Departments.Add(existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + string route = $"/departments/{existingDepartment.StringId}/relationships/company"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'departments' with ID '{existingDepartment.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasOne_relationship_to_soft_deleted() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingDepartment, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "companies", + id = existingCompany.StringId + } + }; + + string route = $"/departments/{existingDepartment.StringId}/relationships/company"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'companies' with ID '{existingCompany.StringId}' in relationship 'company' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_for_soft_deleted_parent() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + Department existingDepartment = _fakers.Department.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingCompany, existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'companies' with ID '{existingCompany.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_with_soft_deleted() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingCompany, existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should() + .Be($"Related resource of type 'departments' with ID '{existingDepartment.StringId}' in relationship 'departments' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_for_soft_deleted_parent() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + existingCompany.Departments = _fakers.Department.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingCompany.Departments.ElementAt(0).StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'companies' with ID '{existingCompany.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_with_soft_deleted() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.Departments = _fakers.Department.Generate(1); + existingCompany.Departments.ElementAt(0).SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingCompany.Departments.ElementAt(0).StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should().Be( + $"Related resource of type 'departments' with ID '{existingCompany.Departments.ElementAt(0).StringId}' in relationship 'departments' does not exist."); + } + + [Fact] + public async Task Can_soft_delete_resource() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + string route = "/companies/" + existingCompany.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Company companyInDatabase = await dbContext.Companies.IgnoreQueryFilters().FirstWithIdAsync(existingCompany.Id); + + companyInDatabase.Name.Should().Be(existingCompany.Name); + companyInDatabase.SoftDeletedAt.Should().NotBeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_soft_deleted_resource() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Departments.Add(existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + string route = "/departments/" + existingDepartment.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'departments' with ID '{existingDepartment.StringId}' does not exist."); } } } From 0715d0e6bffc957942a1edc2bcf595793ea7eb6d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 16 Apr 2021 17:55:54 +0200 Subject: [PATCH 06/22] Added tests for multi-tenancy --- .../Services/JsonApiResourceService.cs | 7 +- .../MultiTenancy/FakeTenantProvider.cs | 17 + .../MultiTenancy/IHasTenant.cs | 11 + .../MultiTenancy/ITenantProvider.cs | 9 + .../MultiTenancy/MultiTenancyDbContext.cs | 36 + .../MultiTenancy/MultiTenancyFakers.cs | 26 + .../MultiTenancy/MultiTenancyTests.cs | 989 ++++++++++++++++++ .../MultiTenantResourceService.cs | 100 ++ .../MultiTenancy/WebProduct.cs | 19 + .../MultiTenancy/WebProductsController.cs | 15 + .../IntegrationTests/MultiTenancy/WebShop.cs | 20 + .../MultiTenancy/WebShopsController.cs | 15 + 12 files changed, 1263 insertions(+), 1 deletion(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 631f2720d5..14628a7538 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -201,7 +201,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); - await _resourceDefinitionAccessor.OnInitializeResourceAsync(resourceForDatabase, cancellationToken); + await InitializeResourceAsync(resourceForDatabase, cancellationToken); try { @@ -243,6 +243,11 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio return resourceFromDatabase; } + protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + { + await _resourceDefinitionAccessor.OnInitializeResourceAsync(resourceForDatabase, cancellationToken); + } + protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) { var missingResources = new List(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs new file mode 100644 index 0000000000..dafbe11176 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs @@ -0,0 +1,17 @@ +using System; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + internal sealed class FakeTenantProvider : ITenantProvider + { + public Guid TenantId { get; } + + public FakeTenantProvider(Guid tenantId) + { + // A real implementation would be registered at request scope and obtain the tenant ID from the request, for example + // from the incoming authentication token, a custom HTTP header, the route or a query string parameter. + + TenantId = tenantId; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs new file mode 100644 index 0000000000..055f86aef8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs @@ -0,0 +1,11 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public interface IHasTenant + { + Guid TenantId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs new file mode 100644 index 0000000000..7d0872b20f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs @@ -0,0 +1,9 @@ +using System; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + public interface ITenantProvider + { + Guid TenantId { get; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs new file mode 100644 index 0000000000..d45206c35c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs @@ -0,0 +1,36 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class MultiTenancyDbContext : DbContext + { + private readonly ITenantProvider _tenantProvider; + + public DbSet WebShops { get; set; } + public DbSet WebProducts { get; set; } + + public MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) + : base(options) + { + _tenantProvider = tenantProvider; + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(webShop => webShop.Products) + .WithOne(webProduct => webProduct.Shop) + .IsRequired(); + + builder.Entity() + .HasQueryFilter(webShop => webShop.TenantId == _tenantProvider.TenantId); + + builder.Entity() + .HasQueryFilter(webProduct => webProduct.Shop.TenantId == _tenantProvider.TenantId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs new file mode 100644 index 0000000000..6f1e0cf57b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs @@ -0,0 +1,26 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + internal sealed class MultiTenancyFakers : FakerContainer + { + private readonly Lazy> _lazyWebShopFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webShop => webShop.Url, faker => faker.Internet.Url())); + + private readonly Lazy> _lazyWebProductFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webProduct => webProduct.Name, faker => faker.Commerce.ProductName()) + .RuleFor(webProduct => webProduct.Price, faker => faker.Finance.Amount())); + + public Faker WebShop => _lazyWebShopFaker.Value; + public Faker WebProduct => _lazyWebProductFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs new file mode 100644 index 0000000000..d1a42986d0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -0,0 +1,989 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + public sealed class MultiTenancyTests : IClassFixture, MultiTenancyDbContext>> + { + private static readonly Guid ThisTenantId = Guid.NewGuid(); + private static readonly Guid OtherTenantId = Guid.NewGuid(); + + private readonly ExampleIntegrationTestContext, MultiTenancyDbContext> _testContext; + private readonly MultiTenancyFakers _fakers = new MultiTenancyFakers(); + + public MultiTenancyTests(ExampleIntegrationTestContext, MultiTenancyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddSingleton(new FakeTenantProvider(ThisTenantId)); + }); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceService>(); + services.AddResourceService>(); + }); + } + + [Fact] + public async Task Get_primary_resources_excludes_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[1].TenantId = ThisTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/webShops"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); + } + + [Fact] + public async Task Filter_on_primary_resources_excludes_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[0].Products = _fakers.WebProduct.Generate(1); + + shops[1].TenantId = ThisTenantId; + shops[1].Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/webShops?filter=has(products)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); + } + + [Fact] + public async Task Get_primary_resources_with_include_excludes_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[0].Products = _fakers.WebProduct.Generate(1); + + shops[1].TenantId = ThisTenantId; + shops[1].Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/webShops?include=products"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("webShops"); + responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("webProducts"); + responseDocument.Included[0].Id.Should().Be(shops[1].Products[0].StringId); + } + + [Fact] + public async Task Cannot_get_primary_resource_by_ID_from_other_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); + + string route = "/webShops/" + shop.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webShops' with ID '{shop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_get_secondary_resources_from_other_parent_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; + shop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webShops/{shop.StringId}/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webShops' with ID '{shop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_get_secondary_resource_from_other_parent_tenant() + { + // Arrange + WebProduct product = _fakers.WebProduct.Generate(); + product.Shop = _fakers.WebShop.Generate(); + product.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(product); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webProducts/{product.StringId}/shop"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webProducts' with ID '{product.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_get_HasMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; + shop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webShops/{shop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webShops' with ID '{shop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_get_HasOne_relationship_for_other_parent_tenant() + { + // Arrange + WebProduct product = _fakers.WebProduct.Generate(); + product.Shop = _fakers.WebShop.Generate(); + product.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(product); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webProducts/{product.StringId}/relationships/shop"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webProducts' with ID '{product.StringId}' does not exist."); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + string newShopUrl = _fakers.WebShop.Generate().Url; + + var requestBody = new + { + data = new + { + type = "webShops", + attributes = new + { + url = newShopUrl + } + } + }; + + const string route = "/webShops"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["url"].Should().Be(newShopUrl); + responseDocument.SingleData.Relationships.Should().NotBeNull(); + + int newShopId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebShop shopInDatabase = await dbContext.WebShops.FirstWithIdAsync(newShopId); + + shopInDatabase.Url.Should().Be(newShopUrl); + shopInDatabase.TenantId.Should().Be(ThisTenantId); + }); + } + + [Fact] + public async Task Cannot_create_resource_with_HasMany_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + string newShopUrl = _fakers.WebShop.Generate().Url; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webShops", + attributes = new + { + url = newShopUrl + }, + relationships = new + { + products = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + } + } + } + }; + + const string route = "/webShops"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } + + [Fact] + public async Task Cannot_create_resource_with_HasOne_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + + string newProductName = _fakers.WebProduct.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webProducts", + attributes = new + { + name = newProductName + }, + relationships = new + { + shop = new + { + data = new + { + type = "webShops", + id = existingShop.StringId + } + } + } + } + }; + + const string route = "/webProducts"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + string newProductName = _fakers.WebProduct.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webProducts", + id = existingProduct.StringId, + attributes = new + { + name = newProductName + } + } + }; + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebProduct productInDatabase = await dbContext.WebProducts.FirstWithIdAsync(existingProduct.Id); + + productInDatabase.Name.Should().Be(newProductName); + productInDatabase.Price.Should().Be(existingProduct.Price); + }); + } + + [Fact] + public async Task Cannot_update_resource_from_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + string newProductName = _fakers.WebProduct.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webProducts", + id = existingProduct.StringId, + attributes = new + { + name = newProductName + } + } + }; + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_resource_with_HasMany_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; + + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webShops", + id = existingShop.StringId, + relationships = new + { + products = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + } + } + } + }; + + string route = "/webShops/" + existingShop.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } + + [Fact] + public async Task Cannot_update_resource_with_HasOne_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingProduct, existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webProducts", + id = existingProduct.StringId, + relationships = new + { + shop = new + { + data = new + { + type = "webShops", + id = existingShop.StringId + } + } + } + } + }; + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + existingShop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webShops' with ID '{existingShop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasMany_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; + + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasOne_relationship_for_other_parent_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + string route = $"/webProducts/{existingProduct.StringId}/relationships/shop"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasOne_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingProduct, existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webShops", + id = existingShop.StringId + } + }; + + string route = $"/webProducts/{existingProduct.StringId}/relationships/shop"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webShops' with ID '{existingShop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_with_other_tenant() + { + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; + + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + existingShop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingShop.Products[0].StringId + } + } + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webShops' with ID '{existingShop.StringId}' does not exist."); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebProduct productInDatabase = await dbContext.WebProducts.FirstWithIdOrDefaultAsync(existingProduct.Id); + + productInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_resource_from_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs new file mode 100644 index 0000000000..68a8d78c1f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class MultiTenantResourceService : JsonApiResourceService + where TResource : class, IIdentifiable + { + private readonly ITenantProvider _tenantProvider; + + private static bool ResourceHasTenant => typeof(IHasTenant).IsAssignableFrom(typeof(TResource)); + + public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, + IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor) + { + _tenantProvider = tenantProvider; + } + + protected override async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + { + await base.InitializeResourceAsync(resourceForDatabase, cancellationToken); + + if (ResourceHasTenant) + { + Guid tenantId = _tenantProvider.TenantId; + + var resourceWithTenant = (IHasTenant)resourceForDatabase; + resourceWithTenant.TenantId = tenantId; + } + } + + // To optimize performance, the default resource service does not always fetch all resources on write operations. + // We do that here, to assure everything belongs to the active tenant. On mismatch, a 404 error is thrown. + + public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + + return await base.CreateAsync(resource, cancellationToken); + } + + public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + + return await base.UpdateAsync(id, resource, cancellationToken); + } + + public override async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds, + CancellationToken cancellationToken) + { + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + + await base.SetRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + public override async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + + await base.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + + await base.DeleteAsync(id, cancellationToken); + } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class MultiTenantResourceService : MultiTenantResourceService, IResourceService + where TResource : class, IIdentifiable + { + public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, + IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) + : base(tenantProvider, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, + hookExecutor) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs new file mode 100644 index 0000000000..afb75e8ceb --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class WebProduct : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public decimal Price { get; set; } + + [HasOne] + public WebShop Shop { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs new file mode 100644 index 0000000000..fd9f6f0adc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + public sealed class WebProductsController : JsonApiController + { + public WebProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs new file mode 100644 index 0000000000..8620924234 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class WebShop : Identifiable, IHasTenant + { + [Attr] + public string Url { get; set; } + + public Guid TenantId { get; set; } + + [HasMany] + public IList Products { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs new file mode 100644 index 0000000000..03f9ac1adc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + public sealed class WebShopsController : JsonApiController + { + public WebShopsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} From bf1072b2a038d2d1ea956f30494013108f3d8d25 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 26 Apr 2021 21:16:06 +0200 Subject: [PATCH 07/22] Refactor callback wire-ups, adds support for relationships --- .../Middleware/OperationKind.cs | 26 ++- .../EntityFrameworkCoreRepository.cs | 106 +++++++++-- .../Resources/IResourceDefinition.cs | 171 ++++++++++++------ .../Resources/IResourceDefinitionAccessor.cs | 44 ++--- .../Resources/JsonApiResourceDefinition.cs | 27 ++- .../Resources/ResourceDefinitionAccessor.cs | 60 +++--- .../Services/JsonApiResourceService.cs | 53 +++--- .../TelevisionBroadcastDefinition.cs | 68 +++---- .../ServiceCollectionExtensionsTests.cs | 43 ++--- .../ResourceHooks/HooksTestsSetup.cs | 31 ++-- 10 files changed, 388 insertions(+), 241 deletions(-) diff --git a/src/JsonApiDotNetCore/Middleware/OperationKind.cs b/src/JsonApiDotNetCore/Middleware/OperationKind.cs index 7cfbe0f892..abd148106f 100644 --- a/src/JsonApiDotNetCore/Middleware/OperationKind.cs +++ b/src/JsonApiDotNetCore/Middleware/OperationKind.cs @@ -1,15 +1,39 @@ namespace JsonApiDotNetCore.Middleware { /// - /// Lists the functional operation kinds from an atomic:operations request. + /// Lists the functional operation kinds from a resource request or an atomic:operations request. /// public enum OperationKind { + /// + /// Create a new resource with attributes, relationships or both. + /// CreateResource, + + /// + /// Update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent + /// relationships are replaced. + /// UpdateResource, + + /// + /// Delete an existing resource. + /// DeleteResource, + + /// + /// Perform a complete replacement of a relationship on an existing resource. + /// SetRelationship, + + /// + /// Add resources to a to-many relationship. + /// AddToRelationship, + + /// + /// Remove resources from a to-many relationship. + /// RemoveFromRelationship } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index a8782ecd85..e19ec2ea09 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -172,7 +172,11 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { object rightResources = relationship.GetValue(resourceFromRequest); - await UpdateRelationshipAsync(relationship, resourceForDatabase, rightResources, collector, cancellationToken); + + object rightResourcesEdited = await EditRelationshipAsync(resourceForDatabase, relationship, rightResources, OperationKind.CreateResource, + cancellationToken); + + await UpdateRelationshipAsync(relationship, resourceForDatabase, rightResourcesEdited, collector, cancellationToken); } foreach (AttrAttribute attribute in _targetedFields.Attributes) @@ -180,12 +184,36 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); } - await _resourceDefinitionAccessor.OnBeforeCreateResourceAsync(resourceForDatabase, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); DbSet dbSet = _dbContext.Set(); await dbSet.AddAsync(resourceForDatabase, cancellationToken); await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); + } + + private async Task EditRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable)rightResourceIds, + operationKind, cancellationToken); + } + + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet rightResourceIdSet = _collectionConverter.ExtractResources(rightResourceIds).ToHashSet(IdentifiableComparer.Instance); + + await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIdSet, operationKind, + cancellationToken); + + return rightResourceIdSet; + } + + return rightResourceIds; } /// @@ -213,9 +241,12 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r { object rightResources = relationship.GetValue(resourceFromRequest); - AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightResources); + object rightResourcesEdited = await EditRelationshipAsync(resourceFromDatabase, relationship, rightResources, OperationKind.UpdateResource, + cancellationToken); - await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightResources, collector, cancellationToken); + AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightResourcesEdited); + + await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightResourcesEdited, collector, cancellationToken); } foreach (AttrAttribute attribute in _targetedFields.Attributes) @@ -223,9 +254,11 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); } - await _resourceDefinitionAccessor.OnBeforeUpdateResourceAsync(resourceFromDatabase, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); } protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute relationship, TResource leftResource, object rightValue) @@ -238,9 +271,9 @@ protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute rel relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false; } - bool relationshipIsBeingCleared = relationship is HasOneAttribute - ? rightValue == null - : IsToManyRelationshipBeingCleared(relationship, leftResource, rightValue); + bool relationshipIsBeingCleared = relationship is HasManyAttribute hasManyRelationship + ? IsToManyRelationshipBeingCleared(hasManyRelationship, leftResource, rightValue) + : rightValue == null; if (relationshipIsRequired && relationshipIsBeingCleared) { @@ -249,11 +282,11 @@ protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute rel } } - private bool IsToManyRelationshipBeingCleared(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) + private bool IsToManyRelationshipBeingCleared(HasManyAttribute hasManyRelationship, TResource leftResource, object valueToAssign) { ICollection newRightResourceIds = _collectionConverter.ExtractResources(valueToAssign); - object existingRightValue = relationship.GetValue(leftResource); + object existingRightValue = hasManyRelationship.GetValue(leftResource); HashSet existingRightResourceIds = _collectionConverter.ExtractResources(existingRightValue).ToHashSet(IdentifiableComparer.Instance); @@ -271,6 +304,13 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke id }); + // This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker. + // If so, we'll reuse the tracked resource instead of a placeholder resource. + var emptyResource = _resourceFactory.CreateInstance(); + emptyResource.Id = id; + + await _resourceDefinitionAccessor.OnWritingAsync(emptyResource, OperationKind.DeleteResource, cancellationToken); + using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); TResource resource = collector.CreateForId(id); @@ -288,6 +328,8 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke _dbContext.Remove(resource); await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resource, OperationKind.DeleteResource, cancellationToken); } private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) @@ -349,12 +391,19 @@ public virtual async Task SetRelationshipAsync(TResource primaryResource, object RelationshipAttribute relationship = _targetedFields.Relationships.Single(); - AssertIsNotClearingRequiredRelationship(relationship, primaryResource, secondaryResourceIds); + object secondaryResourceIdsEdited = + await EditRelationshipAsync(primaryResource, relationship, secondaryResourceIds, OperationKind.SetRelationship, cancellationToken); + + AssertIsNotClearingRequiredRelationship(relationship, primaryResource, secondaryResourceIdsEdited); using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIds, collector, cancellationToken); + await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIdsEdited, collector, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(primaryResource, OperationKind.SetRelationship, cancellationToken); await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(primaryResource, OperationKind.SetRelationship, cancellationToken); } /// @@ -368,7 +417,9 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet(primaryId, relationship, secondaryResourceIds, cancellationToken); if (secondaryResourceIds.Any()) { @@ -377,7 +428,11 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); - rightResourceIds.ExceptWith(secondaryResourceIds); + if (secondaryResourceIds.Any()) + { + object rightValue = relationship.GetValue(primaryResource); - AssertIsNotClearingRequiredRelationship(relationship, primaryResource, rightResourceIds); + HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + rightResourceIds.ExceptWith(secondaryResourceIds); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - await UpdateRelationshipAsync(relationship, primaryResource, rightResourceIds, collector, cancellationToken); + AssertIsNotClearingRequiredRelationship(relationship, primaryResource, rightResourceIds); - await SaveChangesAsync(cancellationToken); + using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); + await UpdateRelationshipAsync(relationship, primaryResource, rightResourceIds, collector, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(primaryResource, OperationKind.RemoveFromRelationship, cancellationToken); + + await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(primaryResource, OperationKind.RemoveFromRelationship, cancellationToken); + } } protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign, @@ -458,6 +522,8 @@ private bool IsOneToOneRelationship(RelationshipAttribute relationship) protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + try { await _dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index d8a39ae610..525638f03e 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -3,13 +3,14 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources { /// - /// Provides a resource-centric extensibility point for executing custom code when something happens with a resource. The goal here is to reduce the need - /// for overriding the service and repository layers. + /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. /// /// /// The resource type. @@ -21,8 +22,7 @@ public interface IResourceDefinition : IResourceDefinition - /// Provides a resource-centric extensibility point for executing custom code when something happens with a resource. The goal here is to reduce the need - /// for overriding the service and repository layers. + /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. /// /// /// The resource type. @@ -134,104 +134,171 @@ public interface IResourceDefinition IDictionary GetMeta(TResource resource); /// - /// Enables to execute custom logic to initialize a newly instantiated resource during a POST request. This is typically used to assign default values to - /// properties or to side-load-and-attach required relationships. + /// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request. + /// + /// Implementing this method enables to perform validations and make changes to , before the fields from the request are + /// copied into it. + /// + /// + /// For POST resource requests, this method is typically used to assign property default values or to side-load-and-attach required relationships. + /// /// /// - /// A freshly instantiated resource object. + /// The original resource retrieved from the underlying data store, or a freshly instantiated resource in case of a POST resource request. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// , and . + /// Note this intentionally excludes and , because for those + /// endpoints no resource is retrieved upfront. /// /// /// Propagates notification that request handling should be canceled. /// - Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken); + Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); /// - /// Enables to execute custom logic, just before a resource is inserted in the underlying data store, during a POST request. This is typically used to - /// overwrite attributes from the incoming request, such as a creation-timestamp. Another use case is to add a notification message to an outbox table, - /// which gets committed along with the resource write in a single transaction (see https://microservices.io/patterns/data/transactional-outbox.html). + /// Executes before replacing (overwriting) a to-one relationship. + /// + /// Implementing this method enables to perform validations and change , before the relationship is updated. + /// /// - /// - /// The resource with incoming request data applied on it. + /// + /// The original resource retrieved from the underlying data store, that declares . /// - /// - /// Propagates notification that request handling should be canceled. + /// + /// The to-one relationship being replaced or cleared. /// - Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken); - - /// - /// Enables to execute custom logic after a resource has been inserted in the underlying data store, during a POST request. A typical use case is to - /// enqueue a notification message on a service bus. - /// - /// - /// The re-fetched resource after a successful insertion. + /// + /// The replacement resource identifier (or null to clear the relationship), coming from the request. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// and . /// /// /// Propagates notification that request handling should be canceled. /// - Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken); + /// + /// The replacement resource identifier, or null to clear the relationship. + /// + Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken); /// - /// Enables to execute custom logic to validate if the update request can be processed, based on the currently stored resource. A typical use case is to - /// throw when the resource is soft-deleted or archived. + /// Executes before replacing (overwriting) a to-many relationship. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// /// - /// - /// The resource as currently stored in the underlying data store. + /// + /// The original resource retrieved from the underlying data store, that declares . + /// + /// + /// The to-many relationship being replaced. + /// + /// + /// The set of resource identifiers to replace any existing set with, coming from the request. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// and . /// /// /// Propagates notification that request handling should be canceled. /// - Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken); + Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken); /// - /// Enables to execute custom logic, just before a resource is updated in the underlying data store, during a PATCH request. This is typically used to - /// overwrite attributes from the incoming request, such as a last-modification-timestamp. Another use case is to add a notification message to an outbox - /// table, which gets committed along with the resource write in a single transaction (see - /// https://microservices.io/patterns/data/transactional-outbox.html). + /// Executes before adding resources to a to-many relationship, as part of a POST relationship request. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// /// - /// - /// The stored resource with incoming request data applied on it. + /// + /// Identifier of the resource that declares . + /// + /// + /// The to-many relationship being added to. + /// + /// + /// The set of resource identifiers to add to the to-many relationship, coming from the request. /// /// /// Propagates notification that request handling should be canceled. /// - Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken); + Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken); /// - /// Enables to execute custom logic after a resource has been updated in the underlying data store, during a PATCH request. A typical use case is to - /// enqueue a notification message on a service bus. + /// Executes before removing resources from a to-many relationship, as part of a DELETE relationship request. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// /// - /// - /// The re-fetched resource after a successful update. + /// + /// The original resource retrieved from the underlying data store, that declares . + /// + /// + /// The to-many relationship being removed from. + /// + /// + /// The set of resource identifiers to remove from the to-many relationship, coming from the request. /// /// /// Propagates notification that request handling should be canceled. /// - Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken); + Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken); /// - /// Enables to execute custom logic, just before a resource is deleted from the underlying data store, during a DELETE request. This enables to throw in - /// case the user does not have permission, an attempt is made to delete an unarchived resource or a non-closed work item etc. Another use case is to add - /// a notification message to an outbox table, which gets committed along with the resource write in a single transaction (see + /// Executes before writing the changed resource to the underlying data store, as part of a write request. + /// + /// Implementing this method enables to perform validations and make changes to , after the fields from the request have been + /// copied into it. + /// + /// + /// An example usage is to set the last-modification timestamp, overwriting the value from the incoming request. + /// + /// + /// Another use case is to add a notification message to an outbox table, which gets committed along with the resource write in a single transaction (see /// https://microservices.io/patterns/data/transactional-outbox.html). + /// /// - /// - /// The identifier of the resource to delete. + /// + /// The original resource retrieved from the underlying data store (or a freshly instantiated resource in case of a POST resource request), updated with + /// the changes from the incoming request. Exception: In case is or + /// , this is an empty object with only the property set, because for those + /// endpoints no resource is retrieved upfront. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// , , , + /// and . /// /// /// Propagates notification that request handling should be canceled. /// - Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken); + Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); /// - /// Enables to execute custom logic after a resource has been deleted from the underlying data store, during a DELETE request. A typical use case is to - /// enqueue a notification message on a service bus. + /// Executes after successfully writing the changed resource to the underlying data store, as part of a write request. + /// + /// Implementing this method enables to run additional logic, for example enqueue a notification message on a service bus. + /// /// - /// - /// The identifier of the resource to delete. + /// + /// The resource as written to the underlying data store. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// , , , + /// and . /// /// /// Propagates notification that request handling should be canceled. /// - Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken); + Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 4be68bc4e2..4c210f45d1 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources { @@ -49,51 +51,49 @@ public interface IResourceDefinitionAccessor IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance); /// - /// Invokes for the specified resource. + /// Invokes for the specified resource. /// - Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken) + Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes for the specified resource. + /// Invokes for the specified resource. /// - Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes for the specified resource. + /// Invokes for the specified resource. /// - Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes for the specified resource. + /// Invokes for the specified resource. /// - Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable; /// - /// Invokes for the specified resource. + /// Invokes for the specified resource. /// - Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes for the specified resource. + /// Invokes for the specified resource. /// - Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// - /// Invokes for the specified resource ID. - /// - Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource ID. + /// Invokes for the specified resource. /// - Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; } } diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index d679f461a3..2afe96cf67 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -116,49 +117,47 @@ public virtual IDictionary GetMeta(TResource resource) } /// - public virtual Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken) + public virtual Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) { return Task.CompletedTask; } /// - public virtual Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) { - return Task.CompletedTask; - } - - /// - public virtual Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken) - { - return Task.CompletedTask; + return Task.FromResult(rightResourceId); } /// - public virtual Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + public virtual Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) { return Task.CompletedTask; } /// - public virtual Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + public virtual Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) { return Task.CompletedTask; } /// - public virtual Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + public virtual Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) { return Task.CompletedTask; } /// - public virtual Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken) + public virtual Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) { return Task.CompletedTask; } /// - public virtual Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken) + public virtual Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) { return Task.CompletedTask; } diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 00a88e1759..9964e21ec2 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -4,7 +4,9 @@ using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources @@ -92,79 +94,83 @@ public IDictionary GetMeta(Type resourceType, IIdentifiable reso } /// - public async Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken) + public async Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(resource, nameof(resource)); dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnInitializeResourceAsync(resource, cancellationToken); + await resourceDefinition.OnPrepareWriteAsync(resource, operationKind, cancellationToken); } /// - public async Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasOneRelationship, nameof(hasOneRelationship)); dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnBeforeCreateResourceAsync(resource, cancellationToken); + return await resourceDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, operationKind, cancellationToken); } /// - public async Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + public async Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnAfterCreateResourceAsync(resource, cancellationToken); + await resourceDefinition.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, operationKind, cancellationToken); } /// - public async Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + public async Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnAfterGetForUpdateResourceAsync(resource, cancellationToken); + await resourceDefinition.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); } /// - public async Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + public async Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnBeforeUpdateResourceAsync(resource, cancellationToken); + await resourceDefinition.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); } /// - public async Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + public async Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(resource, nameof(resource)); dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnAfterUpdateResourceAsync(resource, cancellationToken); + await resourceDefinition.OnWritingAsync(resource, operationKind, cancellationToken); } /// - public async Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + public async Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable { - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnBeforeDeleteResourceAsync(id, cancellationToken); - } + ArgumentGuard.NotNull(resource, nameof(resource)); - /// - public async Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnAfterDeleteResourceAsync(id, cancellationToken); + await resourceDefinition.OnWriteSucceededAsync(resource, operationKind, cancellationToken); } protected virtual object ResolveResourceDefinition(Type resourceType) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 14628a7538..465dcc13b8 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -226,8 +226,6 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); - await _resourceDefinitionAccessor.OnAfterCreateResourceAsync(resourceFromDatabase, cancellationToken); - _hookExecutor.AfterCreate(resourceFromDatabase); _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); @@ -245,7 +243,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) { - await _resourceDefinitionAccessor.OnInitializeResourceAsync(resourceForDatabase, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); } protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) @@ -303,25 +301,22 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, string rel AssertHasRelationship(_request.Relationship, relationshipName); - if (secondaryResourceIds.Any()) + if (secondaryResourceIds.Any() && _request.Relationship is HasManyThroughAttribute hasManyThrough) { - if (_request.Relationship is HasManyThroughAttribute hasManyThrough) - { - // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a - // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. - await RemoveExistingIdsFromSecondarySetAsync(primaryId, secondaryResourceIds, hasManyThrough, cancellationToken); - } + // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a + // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. + await RemoveExistingIdsFromSecondarySetAsync(primaryId, secondaryResourceIds, hasManyThrough, cancellationToken); + } - try - { - await _repositoryAccessor.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, cancellationToken); - } - catch (DataStoreUpdateException) - { - _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); - await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); - throw; - } + try + { + await _repositoryAccessor.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, cancellationToken); + } + catch (DataStoreUpdateException) + { + _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + throw; } } @@ -379,7 +374,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); - await _resourceDefinitionAccessor.OnAfterGetForUpdateResourceAsync(resourceFromDatabase, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); try { @@ -393,8 +388,6 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); - await _resourceDefinitionAccessor.OnAfterUpdateResourceAsync(afterResourceFromDatabase, cancellationToken); - _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); @@ -426,6 +419,8 @@ public virtual async Task SetRelationshipAsync(TId primaryId, string relationshi TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.SetRelationship, cancellationToken); + _hookExecutor.BeforeUpdateRelationship(resourceFromDatabase); try @@ -451,8 +446,6 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke _hookExecutor.BeforeDelete(id); - await _resourceDefinitionAccessor.OnBeforeDeleteResourceAsync(id, cancellationToken); - try { await _repositoryAccessor.DeleteAsync(id, cancellationToken); @@ -463,8 +456,6 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke throw; } - await _resourceDefinitionAccessor.OnAfterDeleteResourceAsync(id, cancellationToken); - _hookExecutor.AfterDelete(id); } @@ -485,12 +476,12 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId primaryId, strin AssertHasRelationship(_request.Relationship, relationshipName); TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId, cancellationToken); + + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.RemoveFromRelationship, cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); - if (secondaryResourceIds.Any()) - { - await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds, cancellationToken); - } + await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds, cancellationToken); } protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 7ee460e2de..5a1e1bbc0d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -119,11 +119,28 @@ private bool HasFilterOnArchivedAt(FilterExpression existingFilter) return walker.HasFilterOnArchivedAt; } - public override Task OnBeforeCreateResourceAsync(TelevisionBroadcast broadcast, CancellationToken cancellationToken) + public override async Task OnWritingAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) { - AssertIsNotArchived(broadcast); + if (operationKind == OperationKind.CreateResource) + { + AssertIsNotArchived(broadcast); + } + else if (operationKind == OperationKind.UpdateResource) + { + AssertIsNotShiftingArchiveDate(broadcast); + } + else if (operationKind == OperationKind.DeleteResource) + { + TelevisionBroadcast broadcastToDelete = + await _dbContext.Broadcasts.FirstOrDefaultAsync(resource => resource.Id == broadcast.Id, cancellationToken); - return base.OnBeforeCreateResourceAsync(broadcast, cancellationToken); + if (broadcastToDelete != null) + { + AssertIsArchived(broadcastToDelete); + } + } + + await base.OnWritingAsync(broadcast, operationKind, cancellationToken); } [AssertionMethod] @@ -138,24 +155,10 @@ private static void AssertIsNotArchived(TelevisionBroadcast broadcast) } } - public override Task OnAfterGetForUpdateResourceAsync(TelevisionBroadcast resource, CancellationToken cancellationToken) - { - _storedArchivedAt = resource.ArchivedAt; - - return base.OnAfterGetForUpdateResourceAsync(resource, cancellationToken); - } - - public override Task OnBeforeUpdateResourceAsync(TelevisionBroadcast resource, CancellationToken cancellationToken) - { - AssertIsNotShiftingArchiveDate(resource); - - return base.OnBeforeUpdateResourceAsync(resource, cancellationToken); - } - [AssertionMethod] - private void AssertIsNotShiftingArchiveDate(TelevisionBroadcast resource) + private void AssertIsNotShiftingArchiveDate(TelevisionBroadcast broadcast) { - if (_storedArchivedAt != null && resource.ArchivedAt != null && _storedArchivedAt != resource.ArchivedAt) + if (_storedArchivedAt != null && broadcast.ArchivedAt != null && _storedArchivedAt != broadcast.ArchivedAt) { throw new JsonApiException(new Error(HttpStatusCode.Forbidden) { @@ -164,23 +167,10 @@ private void AssertIsNotShiftingArchiveDate(TelevisionBroadcast resource) } } - public override async Task OnBeforeDeleteResourceAsync(int broadcastId, CancellationToken cancellationToken) - { - TelevisionBroadcast televisionBroadcast = - await _dbContext.Broadcasts.FirstOrDefaultAsync(broadcast => broadcast.Id == broadcastId, cancellationToken); - - if (televisionBroadcast != null) - { - AssertIsArchived(televisionBroadcast); - } - - await base.OnBeforeDeleteResourceAsync(broadcastId, cancellationToken); - } - [AssertionMethod] - private static void AssertIsArchived(TelevisionBroadcast televisionBroadcast) + private static void AssertIsArchived(TelevisionBroadcast broadcast) { - if (televisionBroadcast.ArchivedAt == null) + if (broadcast.ArchivedAt == null) { throw new JsonApiException(new Error(HttpStatusCode.Forbidden) { @@ -189,6 +179,16 @@ private static void AssertIsArchived(TelevisionBroadcast televisionBroadcast) } } + public override Task OnPrepareWriteAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.UpdateResource) + { + _storedArchivedAt = broadcast.ArchivedAt; + } + + return base.OnPrepareWriteAsync(broadcast, operationKind, cancellationToken); + } + private sealed class FilterWalker : QueryExpressionRewriter { public bool HasFilterOnArchivedAt { get; private set; } diff --git a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index 044f9b0634..67bc31e047 100644 --- a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Services; @@ -487,42 +488,39 @@ public IDictionary GetMeta(IntResource resource) throw new NotImplementedException(); } - public Task OnInitializeResourceAsync(IntResource resource, CancellationToken cancellationToken) + public Task OnPrepareWriteAsync(IntResource resource, OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnBeforeCreateResourceAsync(IntResource resource, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task OnAfterCreateResourceAsync(IntResource resource, CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(IntResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, OperationKind operationKind, + CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnAfterGetForUpdateResourceAsync(IntResource resource, CancellationToken cancellationToken) + public Task OnSetToManyRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, OperationKind operationKind, + CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnBeforeUpdateResourceAsync(IntResource resource, CancellationToken cancellationToken) + public Task OnAddToRelationshipAsync(int leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnAfterUpdateResourceAsync(IntResource resource, CancellationToken cancellationToken) + public Task OnRemoveFromRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnBeforeDeleteResourceAsync(int id, CancellationToken cancellationToken) + public Task OnWritingAsync(IntResource resource, OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnAfterDeleteResourceAsync(int id, CancellationToken cancellationToken) + public Task OnWriteSucceededAsync(IntResource resource, OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -566,42 +564,39 @@ public IDictionary GetMeta(GuidResource resource) throw new NotImplementedException(); } - public Task OnInitializeResourceAsync(GuidResource resource, CancellationToken cancellationToken) + public Task OnPrepareWriteAsync(GuidResource resource, OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnBeforeCreateResourceAsync(GuidResource resource, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task OnAfterCreateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(GuidResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, OperationKind operationKind, + CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnAfterGetForUpdateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + public Task OnSetToManyRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, OperationKind operationKind, + CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnBeforeUpdateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + public Task OnAddToRelationshipAsync(Guid leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnAfterUpdateResourceAsync(GuidResource resource, CancellationToken cancellationToken) + public Task OnRemoveFromRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnBeforeDeleteResourceAsync(Guid id, CancellationToken cancellationToken) + public Task OnWritingAsync(GuidResource resource, OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnAfterDeleteResourceAsync(Guid id, CancellationToken cancellationToken) + public Task OnWriteSucceededAsync(GuidResource resource, OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/test/UnitTests/ResourceHooks/HooksTestsSetup.cs b/test/UnitTests/ResourceHooks/HooksTestsSetup.cs index a4c92edb20..5d463de0dd 100644 --- a/test/UnitTests/ResourceHooks/HooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/HooksTestsSetup.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCore.Hooks.Internal.Discovery; using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCore.Hooks.Internal.Traversal; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; @@ -469,50 +470,48 @@ public IDictionary GetMeta(Type resourceType, IIdentifiable reso return null; } - public Task OnInitializeResourceAsync(TResource resource, CancellationToken cancellationToken) + public Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable { return Task.CompletedTask; } - public Task OnBeforeCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - return Task.CompletedTask; + return Task.FromResult(rightResourceId); } - public Task OnAfterCreateResourceAsync(TResource resource, CancellationToken cancellationToken) + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, OperationKind operationKind, + CancellationToken cancellationToken) where TResource : class, IIdentifiable { return Task.CompletedTask; } - public Task OnAfterGetForUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable { return Task.CompletedTask; } - public Task OnBeforeUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) where TResource : class, IIdentifiable { return Task.CompletedTask; } - public Task OnAfterUpdateResourceAsync(TResource resource, CancellationToken cancellationToken) + public Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable { return Task.CompletedTask; } - public Task OnBeforeDeleteResourceAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnAfterDeleteResourceAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + public Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable { return Task.CompletedTask; } From 149af4d4b1e932c784fe1427f4abed3e24598928 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 27 Apr 2021 02:17:00 +0200 Subject: [PATCH 08/22] Wire-up callbacks for serialization --- .../JsonApiSerializerBenchmarks.cs | 6 +- .../Resources/IResourceDefinition.cs | 38 +++++- .../Resources/IResourceDefinitionAccessor.cs | 10 ++ .../Resources/JsonApiResourceDefinition.cs | 10 ++ .../Resources/ResourceDefinitionAccessor.cs | 18 +++ .../AtomicOperationsResponseSerializer.cs | 7 +- .../Building/IncludedResourceObjectBuilder.cs | 40 ++++--- .../Serialization/RequestDeserializer.cs | 12 ++ .../Serialization/ResponseSerializer.cs | 16 ++- .../ServiceCollectionExtensionsTests.cs | 48 ++++++-- .../Models/ResourceConstructionTests.cs | 15 ++- .../NeverResourceDefinitionAccessor.cs | 103 ++++++++++++++++ .../ResourceHooks/HooksTestsSetup.cs | 113 ------------------ .../Serialization/SerializerTestsSetup.cs | 12 +- .../Server/RequestDeserializerTests.cs | 5 +- test/UnitTests/TestResourceFactory.cs | 30 +++++ 16 files changed, 326 insertions(+), 157 deletions(-) create mode 100644 test/UnitTests/NeverResourceDefinitionAccessor.cs create mode 100644 test/UnitTests/TestResourceFactory.cs diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index 7c42e26685..3d9cefc600 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -36,8 +36,10 @@ public JsonApiSerializerBenchmarks() var resourceObjectBuilder = new ResourceObjectBuilder(resourceGraph, new ResourceObjectBuilderSettings()); - _jsonApiSerializer = new ResponseSerializer(metaBuilder, linkBuilder, - includeBuilder, fieldsToSerialize, resourceObjectBuilder, options); + IResourceDefinitionAccessor resourceDefinitionAccessor = new Mock().Object; + + _jsonApiSerializer = new ResponseSerializer(metaBuilder, linkBuilder, includeBuilder, fieldsToSerialize, resourceObjectBuilder, + resourceDefinitionAccessor, options); } private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resourceGraph) diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 525638f03e..96baa1db52 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -160,7 +160,7 @@ public interface IResourceDefinition /// /// Executes before replacing (overwriting) a to-one relationship. /// - /// Implementing this method enables to perform validations and change , before the relationship is updated. + /// Implementing this method enables to perform validations and change , before the relationship is updated. /// /// /// @@ -269,8 +269,8 @@ Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasM /// /// The original resource retrieved from the underlying data store (or a freshly instantiated resource in case of a POST resource request), updated with /// the changes from the incoming request. Exception: In case is or - /// , this is an empty object with only the property set, because for those - /// endpoints no resource is retrieved upfront. + /// , this is an empty object with only the property set, because for + /// those endpoints no resource is retrieved upfront. /// /// /// Identifies from which endpoint this method was called. Possible values: , @@ -300,5 +300,37 @@ Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasM /// Propagates notification that request handling should be canceled. /// Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); + + /// + /// Executes after a resource has been deserialized from an incoming request body. + /// + /// + /// Implementing this method enables to change the incoming resource before it enters an ASP.NET Controller Action method. + /// + /// + /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests, because + /// side effect detection considers any changes done from this method to be part of the incoming request body. So setting additional attributes from this + /// method (that were not sent by the client) are not considered side effects, resulting in incorrectly reporting that there were no side effects. + /// + /// + /// The deserialized resource. + /// + void OnDeserialize(TResource resource); + + /// + /// Executes before a (primary or included) resource is serialized into an outgoing response body. + /// + /// + /// Implementing this method enables to change the returned resource, for example scrub sensitive data or transform returned attribute values. + /// + /// + /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests. What this + /// means is that if side effects were detected before, this is not re-evaluated after running this method, so it may incorrectly report side effects if + /// they were undone by this method. + /// + /// + /// The serialized resource. + /// + void OnSerialize(TResource resource); } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 4c210f45d1..042a5553c4 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -95,5 +95,15 @@ Task OnWritingAsync(TResource resource, OperationKind operationKind, /// Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + void OnDeserialize(IIdentifiable resource); + + /// + /// Invokes for the specified resource. + /// + void OnSerialize(IIdentifiable resource); } } diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index 2afe96cf67..74aac437be 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -162,6 +162,16 @@ public virtual Task OnWriteSucceededAsync(TResource resource, OperationKind oper return Task.CompletedTask; } + /// + public virtual void OnDeserialize(TResource resource) + { + } + + /// + public virtual void OnSerialize(TResource resource) + { + } + /// /// This is an alias type intended to simplify the implementation's method signature. See for usage /// details. diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 9964e21ec2..b6ed7774ca 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -173,6 +173,24 @@ public async Task OnWriteSucceededAsync(TResource resource, Operation await resourceDefinition.OnWriteSucceededAsync(resource, operationKind, cancellationToken); } + /// + public void OnDeserialize(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); + resourceDefinition.OnDeserialize((dynamic)resource); + } + + /// + public void OnSerialize(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); + resourceDefinition.OnSerialize((dynamic)resource); + } + protected virtual object ResolveResourceDefinition(Type resourceType) { ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index aeb773dd88..19b95d52b4 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -20,6 +20,7 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp private readonly IMetaBuilder _metaBuilder; private readonly ILinkBuilder _linkBuilder; private readonly IFieldsToSerialize _fieldsToSerialize; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; @@ -27,18 +28,20 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, - IFieldsToSerialize fieldsToSerialize, IJsonApiRequest request, IJsonApiOptions options) + IFieldsToSerialize fieldsToSerialize, IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiRequest request, IJsonApiOptions options) : base(resourceObjectBuilder) { ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(options, nameof(options)); _metaBuilder = metaBuilder; _linkBuilder = linkBuilder; _fieldsToSerialize = fieldsToSerialize; + _resourceDefinitionAccessor = resourceDefinitionAccessor; _request = request; _options = options; } @@ -79,6 +82,8 @@ private AtomicResultObject SerializeOperation(OperationContainer operation) _request.CopyFrom(operation.Request); _fieldsToSerialize.ResetCache(); + _resourceDefinitionAccessor.OnSerialize(operation.Resource); + Type resourceType = operation.Resource.GetType(); IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(resourceType); IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(resourceType); diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs index 7db926c6da..b0bb81f44b 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs @@ -135,8 +135,14 @@ private void ProcessChain(object related, IList inclusion private void ProcessRelationship(IIdentifiable parent, IList inclusionChain) { - // get the resource object for parent. - ResourceObject resourceObject = GetOrBuildResourceObject(parent); + ResourceObject resourceObject = TryGetBuiltResourceObjectFor(parent); + + if (resourceObject == null) + { + _resourceDefinitionAccessor.OnSerialize(parent); + + resourceObject = BuildCachedResourceObjectFor(parent); + } if (!inclusionChain.Any()) { @@ -188,23 +194,25 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r }; } - /// - /// Gets the resource object for by searching the included list. If it was not already built, it is constructed and added to - /// the inclusion list. - /// - private ResourceObject GetOrBuildResourceObject(IIdentifiable parent) + private ResourceObject TryGetBuiltResourceObjectFor(IIdentifiable resource) { - Type type = parent.GetType(); - string resourceName = ResourceContextProvider.GetResourceContext(type).PublicName; - ResourceObject entry = _included.SingleOrDefault(ro => ro.Type == resourceName && ro.Id == parent.StringId); + Type resourceType = resource.GetType(); + ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(resourceType); - if (entry == null) - { - entry = Build(parent, _fieldsToSerialize.GetAttributes(type), _fieldsToSerialize.GetRelationships(type)); - _included.Add(entry); - } + return _included.SingleOrDefault(resourceObject => resourceObject.Type == resourceContext.PublicName && resourceObject.Id == resource.StringId); + } + + private ResourceObject BuildCachedResourceObjectFor(IIdentifiable resource) + { + Type resourceType = resource.GetType(); + IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(resourceType); + IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(resourceType); - return entry; + ResourceObject resourceObject = Build(resource, attributes, relationships); + + _included.Add(resourceObject); + + return resourceObject; } } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 52e8db1fb0..9ab6a48866 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -26,6 +26,7 @@ public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer private readonly IHttpContextAccessor _httpContextAccessor; private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; public RequestDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields, IHttpContextAccessor httpContextAccessor, IJsonApiRequest request, IJsonApiOptions options) @@ -40,6 +41,10 @@ public RequestDeserializer(IResourceContextProvider resourceContextProvider, IRe _httpContextAccessor = httpContextAccessor; _request = request; _options = options; + +#pragma warning disable 612 // Method is obsolete + _resourceDefinitionAccessor = resourceFactory.GetResourceDefinitionAccessor(); +#pragma warning restore 612 } /// @@ -59,6 +64,11 @@ public object Deserialize(string body) object instance = DeserializeBody(body); + if (instance is IIdentifiable resource) + { + _resourceDefinitionAccessor.OnDeserialize(resource); + } + AssertResourceIdIsNotTargeted(_targetedFields); return instance; @@ -204,6 +214,8 @@ private OperationContainer ParseForCreateOrUpdateResourceOperation(AtomicOperati IIdentifiable primaryResource = ParseResourceObject(operation.SingleData); + _resourceDefinitionAccessor.OnDeserialize(primaryResource); + request.PrimaryId = primaryResource.StringId; _request.CopyFrom(request); diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index fb0b60f5fd..343de00a60 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -31,6 +31,7 @@ public class ResponseSerializer : BaseSerializer, IJsonApiSerializer private readonly ILinkBuilder _linkBuilder; private readonly IIncludedResourceObjectBuilder _includedBuilder; private readonly IFieldsToSerialize _fieldsToSerialize; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IJsonApiOptions _options; private readonly Type _primaryResourceType; @@ -38,19 +39,22 @@ public class ResponseSerializer : BaseSerializer, IJsonApiSerializer public string ContentType { get; } = HeaderConstants.MediaType; public ResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, IIncludedResourceObjectBuilder includedBuilder, - IFieldsToSerialize fieldsToSerialize, IResourceObjectBuilder resourceObjectBuilder, IJsonApiOptions options) + IFieldsToSerialize fieldsToSerialize, IResourceObjectBuilder resourceObjectBuilder, IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiOptions options) : base(resourceObjectBuilder) { ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); ArgumentGuard.NotNull(includedBuilder, nameof(includedBuilder)); ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); ArgumentGuard.NotNull(options, nameof(options)); _metaBuilder = metaBuilder; _linkBuilder = linkBuilder; _includedBuilder = includedBuilder; _fieldsToSerialize = fieldsToSerialize; + _resourceDefinitionAccessor = resourceDefinitionAccessor; _options = options; _primaryResourceType = typeof(TResource); } @@ -92,6 +96,11 @@ private string SerializeErrorDocument(ErrorDocument errorDocument) /// internal string SerializeSingle(IIdentifiable resource) { + if (resource != null) + { + _resourceDefinitionAccessor.OnSerialize(resource); + } + IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(_primaryResourceType); @@ -119,6 +128,11 @@ internal string SerializeSingle(IIdentifiable resource) /// internal string SerializeMany(IReadOnlyCollection resources) { + foreach (IIdentifiable resource in resources) + { + _resourceDefinitionAccessor.OnSerialize(resource); + } + IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(_primaryResourceType); diff --git a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index 67bc31e047..603ac39aa3 100644 --- a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -493,24 +493,26 @@ public Task OnPrepareWriteAsync(IntResource resource, OperationKind operationKin throw new NotImplementedException(); } - public Task OnSetToOneRelationshipAsync(IntResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, OperationKind operationKind, - CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(IntResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnSetToManyRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, OperationKind operationKind, - CancellationToken cancellationToken) + public Task OnSetToManyRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnAddToRelationshipAsync(int leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) + public Task OnAddToRelationshipAsync(int leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnRemoveFromRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) + public Task OnRemoveFromRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -524,6 +526,16 @@ public Task OnWriteSucceededAsync(IntResource resource, OperationKind operationK { throw new NotImplementedException(); } + + public void OnDeserialize(IntResource resource) + { + throw new NotImplementedException(); + } + + public void OnSerialize(IntResource resource) + { + throw new NotImplementedException(); + } } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] @@ -569,24 +581,26 @@ public Task OnPrepareWriteAsync(GuidResource resource, OperationKind operationKi throw new NotImplementedException(); } - public Task OnSetToOneRelationshipAsync(GuidResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, OperationKind operationKind, - CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(GuidResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnSetToManyRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, OperationKind operationKind, - CancellationToken cancellationToken) + public Task OnSetToManyRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnAddToRelationshipAsync(Guid leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) + public Task OnAddToRelationshipAsync(Guid leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnRemoveFromRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) + public Task OnRemoveFromRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -600,6 +614,16 @@ public Task OnWriteSucceededAsync(GuidResource resource, OperationKind operation { throw new NotImplementedException(); } + + public void OnDeserialize(GuidResource resource) + { + throw new NotImplementedException(); + } + + public void OnSerialize(GuidResource resource) + { + throw new NotImplementedException(); + } } [UsedImplicitly(ImplicitUseTargetFlags.Members)] diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index 9d479a1ef9..f31d140029 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -33,7 +33,10 @@ public void When_resource_has_default_constructor_it_must_succeed() IResourceGraph graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, + var serviceContainer = new ServiceContainer(); + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); + + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new @@ -63,7 +66,10 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() IResourceGraph graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, + var serviceContainer = new ServiceContainer(); + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); + + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new @@ -95,7 +101,10 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() IResourceGraph graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, + var serviceContainer = new ServiceContainer(); + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); + + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new diff --git a/test/UnitTests/NeverResourceDefinitionAccessor.cs b/test/UnitTests/NeverResourceDefinitionAccessor.cs new file mode 100644 index 0000000000..ddc89a95ba --- /dev/null +++ b/test/UnitTests/NeverResourceDefinitionAccessor.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace UnitTests +{ + internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor + { + public IReadOnlyCollection OnApplyIncludes(Type resourceType, IReadOnlyCollection existingIncludes) + { + return existingIncludes; + } + + public FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter) + { + return existingFilter; + } + + public SortExpression OnApplySort(Type resourceType, SortExpression existingSort) + { + return existingSort; + } + + public PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination) + { + return existingPagination; + } + + public SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet) + { + return existingSparseFieldSet; + } + + public object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName) + { + return new QueryStringParameterHandlers(); + } + + public IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance) + { + return null; + } + + public Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.FromResult(rightResourceId); + } + + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public void OnDeserialize(IIdentifiable resource) + { + } + + public void OnSerialize(IIdentifiable resource) + { + } + } +} diff --git a/test/UnitTests/ResourceHooks/HooksTestsSetup.cs b/test/UnitTests/ResourceHooks/HooksTestsSetup.cs index 5d463de0dd..d9f288b9a2 100644 --- a/test/UnitTests/ResourceHooks/HooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/HooksTestsSetup.cs @@ -1,16 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Hooks.Internal; using JsonApiDotNetCore.Hooks.Internal.Discovery; using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCore.Hooks.Internal.Traversal; -using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; @@ -408,114 +404,5 @@ public void Deconstruct(out Mock> constrai secondSecondaryResourceContainerMock = SecondSecondaryResourceContainerMock; } } - - private sealed class TestResourceFactory : IResourceFactory - { - public IIdentifiable CreateInstance(Type resourceType) - { - return (IIdentifiable)Activator.CreateInstance(resourceType); - } - - public TResource CreateInstance() - where TResource : IIdentifiable - { - return (TResource)Activator.CreateInstance(typeof(TResource)); - } - - public NewExpression CreateNewExpression(Type resourceType) - { - return Expression.New(resourceType); - } - - public IResourceDefinitionAccessor GetResourceDefinitionAccessor() - { - return new NeverResourceDefinitionAccessor(); - } - - private sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor - { - public IReadOnlyCollection OnApplyIncludes(Type resourceType, - IReadOnlyCollection existingIncludes) - { - return existingIncludes; - } - - public FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter) - { - return existingFilter; - } - - public SortExpression OnApplySort(Type resourceType, SortExpression existingSort) - { - return existingSort; - } - - public PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination) - { - return existingPagination; - } - - public SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet) - { - return existingSparseFieldSet; - } - - public object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName) - { - return new QueryStringParameterHandlers(); - } - - public IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance) - { - return null; - } - - public Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, - OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.FromResult(rightResourceId); - } - - public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, OperationKind operationKind, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - } - } } } diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index a1d33a89e3..ada0a8d7f0 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -56,10 +56,16 @@ protected ResponseSerializer GetResponseSerializer(IEnumerable(meta, link, includedBuilder, fieldsToSerialize, resourceObjectBuilder, new JsonApiOptions()); + return new ResponseSerializer(meta, link, includedBuilder, fieldsToSerialize, resourceObjectBuilder, resourceDefinitionAccessor, jsonApiOptions); } protected ResponseResourceObjectBuilder GetResponseResourceObjectBuilder(IEnumerable> inclusionChains = null, diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index 59ccd70675..1d45e92ffd 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.ComponentModel.Design; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; @@ -20,8 +19,8 @@ public sealed class RequestDeserializerTests : DeserializerTestsSetup public RequestDeserializerTests() { - _deserializer = new RequestDeserializer(ResourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, - MockHttpContextAccessor.Object, _requestMock.Object, new JsonApiOptions()); + _deserializer = new RequestDeserializer(ResourceGraph, new TestResourceFactory(), _fieldsManagerMock.Object, MockHttpContextAccessor.Object, + _requestMock.Object, new JsonApiOptions()); } [Fact] diff --git a/test/UnitTests/TestResourceFactory.cs b/test/UnitTests/TestResourceFactory.cs new file mode 100644 index 0000000000..b27e334650 --- /dev/null +++ b/test/UnitTests/TestResourceFactory.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq.Expressions; +using JsonApiDotNetCore.Resources; + +namespace UnitTests +{ + internal sealed class TestResourceFactory : IResourceFactory + { + public IIdentifiable CreateInstance(Type resourceType) + { + return (IIdentifiable)Activator.CreateInstance(resourceType); + } + + public TResource CreateInstance() + where TResource : IIdentifiable + { + return (TResource)Activator.CreateInstance(typeof(TResource)); + } + + public NewExpression CreateNewExpression(Type resourceType) + { + return Expression.New(resourceType); + } + + public IResourceDefinitionAccessor GetResourceDefinitionAccessor() + { + return new NeverResourceDefinitionAccessor(); + } + } +} From f2972122b7a20b84e8c43661535a236187d9d1e4 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Apr 2021 10:58:39 +0200 Subject: [PATCH 09/22] Moved existing ResourceDefinition tests into subfolder --- .../ResourceDefinitions/{ => Reading}/CallableDbContext.cs | 2 +- .../ResourceDefinitions/{ => Reading}/CallableResource.cs | 2 +- .../{ => Reading}/CallableResourceDefinition.cs | 2 +- .../{ => Reading}/CallableResourcesController.cs | 4 ++-- .../ResourceDefinitions/{ => Reading}/IUserRolesService.cs | 2 +- .../{ => Reading}/ResourceDefinitionQueryCallbackTests.cs | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/{ => Reading}/CallableDbContext.cs (95%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/{ => Reading}/CallableResource.cs (98%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/{ => Reading}/CallableResourceDefinition.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/{ => Reading}/CallableResourcesController.cs (89%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/{ => Reading}/IUserRolesService.cs (88%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/{ => Reading}/ResourceDefinitionQueryCallbackTests.cs (99%) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableDbContext.cs similarity index 95% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableDbContext.cs index 4a137f12a4..3bac89e480 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableDbContext.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CallableDbContext : DbContext diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResource.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResource.cs index 73820a9364..c54bd08317 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResource.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CallableResource : Identifiable diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourceDefinition.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourceDefinition.cs index 539fb013ec..4f00b100b5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourceDefinition.cs @@ -12,7 +12,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class CallableResourceDefinition : JsonApiResourceDefinition diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourcesController.cs similarity index 89% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourcesController.cs index c59db0194b..82707df6dc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourcesController.cs @@ -1,9 +1,9 @@ -using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { public sealed class CallableResourcesController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/IUserRolesService.cs similarity index 88% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/IUserRolesService.cs index 8c0bfb5406..ceb2e170ab 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/IUserRolesService.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { public interface IUserRolesService { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionQueryCallbackTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionQueryCallbackTests.cs index 26a09d690e..0f7e8e157a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionQueryCallbackTests.cs @@ -11,7 +11,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { public sealed class ResourceDefinitionQueryCallbackTests : IClassFixture, CallableDbContext>> From bc1de6a8ea7f8cae8beaaa72989ca1c1b514ffdd Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 29 Apr 2021 17:21:49 +0200 Subject: [PATCH 10/22] Added tests for Transactional Outbox pattern and fire-and-forget --- JsonApiDotNetCore.sln.DotSettings | 1 + .../Microservices/DomainFakers.cs | 26 + .../Microservices/DomainGroup.cs | 18 + .../Microservices/DomainGroupsController.cs | 16 + .../Microservices/DomainUser.cs | 22 + .../Microservices/DomainUsersController.cs | 16 + .../FireForgetDbContext.cs | 17 + .../FireForgetGroupDefinition.cs | 46 ++ .../FireForgetTests.Group.cs | 506 +++++++++++++++ .../FireForgetTests.User.cs | 543 ++++++++++++++++ .../FireAndForgetDelivery/FireForgetTests.cs | 102 +++ .../FireForgetUserDefinition.cs | 46 ++ .../FireAndForgetDelivery/MessageBroker.cs | 40 ++ .../Messages/GroupCreatedContent.cs | 14 + .../Messages/GroupDeletedContent.cs | 13 + .../Messages/GroupRenamedContent.cs | 15 + .../Microservices/Messages/IMessageContent.cs | 8 + .../Microservices/Messages/OutgoingMessage.cs | 33 + .../Messages/UserAddedToGroupContent.cs | 14 + .../Messages/UserCreatedContent.cs | 15 + .../Messages/UserDeletedContent.cs | 13 + .../Messages/UserDisplayNameChangedContent.cs | 15 + .../Messages/UserLoginNameChangedContent.cs | 15 + .../Messages/UserMovedToGroupContent.cs | 15 + .../Messages/UserRemovedFromGroupContent.cs | 14 + .../Microservices/MessagingGroupDefinition.cs | 225 +++++++ .../Microservices/MessagingUserDefinition.cs | 162 +++++ .../OutboxDbContext.cs | 19 + .../OutboxGroupDefinition.cs | 32 + .../OutboxTests.Group.cs | 547 ++++++++++++++++ .../OutboxTests.User.cs | 593 ++++++++++++++++++ .../TransactionalOutboxPattern/OutboxTests.cs | 95 +++ .../OutboxUserDefinition.cs | 32 + 33 files changed, 3288 insertions(+) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 54dbf2a2ec..4b88935aaf 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -621,6 +621,7 @@ $left$ = $right$; True True True + True True True True diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs new file mode 100644 index 0000000000..9e69ec8689 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs @@ -0,0 +1,26 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + internal sealed class DomainFakers : FakerContainer + { + private readonly Lazy> _lazyDomainUserFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(domainUser => domainUser.LoginName, faker => faker.Person.UserName) + .RuleFor(domainUser => domainUser.DisplayName, faker => faker.Person.FullName)); + + private readonly Lazy> _lazyDomainGroupFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department())); + + public Faker DomainUser => _lazyDomainUserFaker.Value; + public Faker DomainGroup => _lazyDomainGroupFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs new file mode 100644 index 0000000000..306aa2c907 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class DomainGroup : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public ISet Users { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs new file mode 100644 index 0000000000..efd737881b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs @@ -0,0 +1,16 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + public sealed class DomainGroupsController : JsonApiController + { + public DomainGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs new file mode 100644 index 0000000000..538b2a31bd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class DomainUser : Identifiable + { + [Attr] + [Required] + public string LoginName { get; set; } + + [Attr] + public string DisplayName { get; set; } + + [HasOne] + public DomainGroup Group { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs new file mode 100644 index 0000000000..48b26e2acd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs @@ -0,0 +1,16 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + public sealed class DomainUsersController : JsonApiController + { + public DomainUsersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs new file mode 100644 index 0000000000..259bafb87e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class FireForgetDbContext : DbContext + { + public DbSet Users { get; set; } + public DbSet Groups { get; set; } + + public FireForgetDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs new file mode 100644 index 0000000000..f0faad036c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class FireForgetGroupDefinition : MessagingGroupDefinition + { + private readonly MessageBroker _messageBroker; + private DomainGroup _groupToDelete; + + public FireForgetGroupDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker) + : base(resourceGraph, dbContext.Users, dbContext.Groups) + { + _messageBroker = messageBroker; + } + + public override async Task OnWritingAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.DeleteResource) + { + _groupToDelete = await base.GetGroupToDeleteAsync(group.Id, cancellationToken); + } + } + + public override Task OnWriteSucceededAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + return FinishWriteAsync(group, operationKind, cancellationToken); + } + + protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + return _messageBroker.PostMessageAsync(message, cancellationToken); + } + + protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + { + return Task.FromResult(_groupToDelete); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs new file mode 100644 index 0000000000..452d267128 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs @@ -0,0 +1,506 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + public sealed partial class FireForgetTests + { + [Fact] + public async Task Create_group_sends_messages() + { + // Arrange + string newGroupName = _fakers.DomainGroup.Generate().Name; + + var requestBody = new + { + data = new + { + type = "domainGroups", + attributes = new + { + name = newGroupName + } + } + }; + + const string route = "/domainGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); + + Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(newGroupId); + content.GroupName.Should().Be(newGroupName); + } + + [Fact] + public async Task Create_group_with_users_sends_messages() + { + // Arrange + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + attributes = new + { + name = newGroupName + }, + relationships = new + { + users = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + } + } + } + }; + + const string route = "/domainGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); + + Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(3); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.GroupId.Should().Be(newGroupId); + content1.GroupName.Should().Be(newGroupName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithoutGroup.Id); + content2.GroupId.Should().Be(newGroupId); + + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithOtherGroup.Id); + content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content3.AfterGroupId.Should().Be(newGroupId); + } + + [Fact] + public async Task Update_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId, + attributes = new + { + name = newGroupName + } + } + }; + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + content.BeforeGroupName.Should().Be(existingGroup.Name); + content.AfterGroupName.Should().Be(newGroupName); + } + + [Fact] + public async Task Update_group_with_users_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId, + relationships = new + { + users = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + } + } + } + }; + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(3); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Delete_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + } + + [Fact] + public async Task Delete_group_with_users_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); + content1.GroupId.Should().Be(existingGroup.StringId); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.GroupId.Should().Be(existingGroup.StringId); + } + + [Fact] + public async Task Replace_users_in_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(3); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Add_users_to_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); + existingUserWithSameGroup.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Remove_users_from_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithSameGroup2.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUserWithSameGroup2.Id); + content.GroupId.Should().Be(existingGroup.Id); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs new file mode 100644 index 0000000000..7c0fe9c3fb --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs @@ -0,0 +1,543 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + public sealed partial class FireForgetTests + { + [Fact] + public async Task Create_user_sends_messages() + { + // Arrange + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + var requestBody = new + { + data = new + { + type = "domainUsers", + attributes = new + { + loginName = newLoginName, + displayName = newDisplayName + } + } + }; + + const string route = "/domainUsers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); + responseDocument.SingleData.Attributes["displayName"].Should().Be(newDisplayName); + + Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(newUserId); + content.UserLoginName.Should().Be(newLoginName); + content.UserDisplayName.Should().Be(newDisplayName); + } + + [Fact] + public async Task Create_user_in_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newLoginName = _fakers.DomainUser.Generate().LoginName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + attributes = new + { + loginName = newLoginName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + const string route = "/domainUsers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); + responseDocument.SingleData.Attributes["displayName"].Should().BeNull(); + + Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(newUserId); + content1.UserLoginName.Should().Be(newLoginName); + content1.UserDisplayName.Should().BeNull(); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(newUserId); + content2.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Update_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + loginName = newLoginName, + displayName = newDisplayName + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); + content1.AfterUserLoginName.Should().Be(newLoginName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content2.AfterUserDisplayName.Should().Be(newDisplayName); + } + + [Fact] + public async Task Update_user_clear_group_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = (object)null + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingUser.Group.Id); + } + + [Fact] + public async Task Update_user_add_to_group_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Update_user_move_to_group_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeGroupId.Should().Be(existingUser.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Delete_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + } + + [Fact] + public async Task Delete_user_in_group_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.GroupId.Should().Be(existingUser.Group.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + } + + [Fact] + public async Task Clear_group_from_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingUser.Group.Id); + } + + [Fact] + public async Task Assign_group_to_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Replace_group_for_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.BeforeGroupId.Should().Be(existingUser.Group.Id); + content.AfterGroupId.Should().Be(existingGroup.Id); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs new file mode 100644 index 0000000000..db834ac227 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + public sealed partial class FireForgetTests : IClassFixture, FireForgetDbContext>> + { + private readonly ExampleIntegrationTestContext, FireForgetDbContext> _testContext; + private readonly DomainFakers _fakers = new DomainFakers(); + + public FireForgetTests(ExampleIntegrationTestContext, FireForgetDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + services.AddResourceDefinition(); + + services.AddSingleton(); + }); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.Reset(); + } + + [Fact] + public async Task Does_not_send_message_on_write_error() + { + // Arrange + string missingUserId = Guid.NewGuid().ToString(); + + string route = "/domainUsers/" + missingUserId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error 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 'domainUsers' with ID '{missingUserId}' does not exist."); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().BeEmpty(); + } + + [Fact] + public async Task Does_not_rollback_on_message_delivery_error() + { + // Arrange + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SimulateFailure = true; + + DomainUser existingUser = _fakers.DomainUser.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.ServiceUnavailable); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + error.Title.Should().Be("Message delivery failed."); + error.Detail.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + DomainUser user = await dbContext.Users.FirstWithIdOrDefaultAsync(existingUser.Id); + user.Should().BeNull(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs new file mode 100644 index 0000000000..e07bf899e7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class FireForgetUserDefinition : MessagingUserDefinition + { + private readonly MessageBroker _messageBroker; + private DomainUser _userToDelete; + + public FireForgetUserDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker) + : base(resourceGraph, dbContext.Users) + { + _messageBroker = messageBroker; + } + + public override async Task OnWritingAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.DeleteResource) + { + _userToDelete = await base.GetUserToDeleteAsync(user.Id, cancellationToken); + } + } + + public override Task OnWriteSucceededAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + return FinishWriteAsync(user, operationKind, cancellationToken); + } + + protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + return _messageBroker.PostMessageAsync(message, cancellationToken); + } + + protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + { + return Task.FromResult(_userToDelete); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs new file mode 100644 index 0000000000..1de7fed1a1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + public sealed class MessageBroker + { + internal IList SentMessages { get; } = new List(); + + internal bool SimulateFailure { get; set; } + + internal void Reset() + { + SimulateFailure = false; + SentMessages.Clear(); + } + + internal Task PostMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + if (SimulateFailure) + { + throw new JsonApiException(new Error(HttpStatusCode.ServiceUnavailable) + { + Title = "Message delivery failed." + }); + } + + cancellationToken.ThrowIfCancellationRequested(); + + SentMessages.Add(message); + + return Task.CompletedTask; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs new file mode 100644 index 0000000000..78e0d34b90 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs @@ -0,0 +1,14 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class GroupCreatedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid GroupId { get; set; } + public string GroupName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs new file mode 100644 index 0000000000..c168671ba6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs @@ -0,0 +1,13 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class GroupDeletedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid GroupId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs new file mode 100644 index 0000000000..0da1eb58ff --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class GroupRenamedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid GroupId { get; set; } + public string BeforeGroupName { get; set; } + public string AfterGroupName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs new file mode 100644 index 0000000000..e95b74e673 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + public interface IMessageContent + { + // Increment when content structure changes. + int FormatVersion { get; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs new file mode 100644 index 0000000000..68837f0d12 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs @@ -0,0 +1,33 @@ +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class OutgoingMessage + { + public long Id { get; set; } + public string Type { get; set; } + public int FormatVersion { get; set; } + public string Content { get; set; } + + public T GetContentAs() + where T : IMessageContent + { + string namespacePrefix = typeof(IMessageContent).Namespace; + var contentType = System.Type.GetType(namespacePrefix + "." + Type, true); + + return (T)JsonConvert.DeserializeObject(Content, contentType); + } + + public static OutgoingMessage CreateFromContent(IMessageContent content) + { + return new OutgoingMessage + { + Type = content.GetType().Name, + FormatVersion = content.FormatVersion, + Content = JsonConvert.SerializeObject(content) + }; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs new file mode 100644 index 0000000000..209f1d4035 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs @@ -0,0 +1,14 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserAddedToGroupContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public Guid GroupId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs new file mode 100644 index 0000000000..ebbffa4152 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserCreatedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public string UserLoginName { get; set; } + public string UserDisplayName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs new file mode 100644 index 0000000000..94c77c0b49 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs @@ -0,0 +1,13 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserDeletedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs new file mode 100644 index 0000000000..f461de5807 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserDisplayNameChangedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public string BeforeUserDisplayName { get; set; } + public string AfterUserDisplayName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs new file mode 100644 index 0000000000..b6abe8a478 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserLoginNameChangedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public string BeforeUserLoginName { get; set; } + public string AfterUserLoginName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs new file mode 100644 index 0000000000..1ef56bc316 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserMovedToGroupContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public Guid BeforeGroupId { get; set; } + public Guid AfterGroupId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs new file mode 100644 index 0000000000..82cce6824e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs @@ -0,0 +1,14 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserRemovedFromGroupContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public Guid GroupId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs new file mode 100644 index 0000000000..9d99fe6793 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + public abstract class MessagingGroupDefinition : JsonApiResourceDefinition + { + private readonly DbSet _userSet; + private readonly DbSet _groupSet; + private readonly List _pendingMessages = new List(); + + private string _beforeGroupName; + + protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet) + : base(resourceGraph) + { + _userSet = userSet; + _groupSet = groupSet; + } + + public override Task OnPrepareWriteAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + group.Id = Guid.NewGuid(); + } + else if (operationKind == OperationKind.UpdateResource) + { + _beforeGroupName = group.Name; + } + + return Task.CompletedTask; + } + + public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + { + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) + { + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); + + List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) + .ToListAsync(cancellationToken); + + foreach (DomainUser beforeUser in beforeUsers) + { + IMessageContent content = null; + + if (beforeUser.Group == null) + { + content = new UserAddedToGroupContent + { + UserId = beforeUser.Id, + GroupId = group.Id + }; + } + else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) + { + content = new UserMovedToGroupContent + { + UserId = beforeUser.Id, + BeforeGroupId = beforeUser.Group.Id, + AfterGroupId = group.Id + }; + } + + if (content != null) + { + _pendingMessages.Add(OutgoingMessage.CreateFromContent(content)); + } + } + + if (group.Users != null) + { + foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => !rightUserIds.Contains(user.Id))) + { + var message = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent + { + UserId = userToRemoveFromGroup.Id, + GroupId = group.Id + }); + + _pendingMessages.Add(message); + } + } + } + } + + public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) + { + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); + + List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) + .ToListAsync(cancellationToken); + + foreach (DomainUser beforeUser in beforeUsers) + { + IMessageContent content = null; + + if (beforeUser.Group == null) + { + content = new UserAddedToGroupContent + { + UserId = beforeUser.Id, + GroupId = groupId + }; + } + else if (beforeUser.Group != null && beforeUser.Group.Id != groupId) + { + content = new UserMovedToGroupContent + { + UserId = beforeUser.Id, + BeforeGroupId = beforeUser.Group.Id, + AfterGroupId = groupId + }; + } + + if (content != null) + { + _pendingMessages.Add(OutgoingMessage.CreateFromContent(content)); + } + } + } + } + + public override Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) + { + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); + + foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => rightUserIds.Contains(user.Id))) + { + var message = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent + { + UserId = userToRemoveFromGroup.Id, + GroupId = group.Id + }); + + _pendingMessages.Add(message); + } + } + + return Task.CompletedTask; + } + + protected async Task FinishWriteAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + var message = OutgoingMessage.CreateFromContent(new GroupCreatedContent + { + GroupId = group.Id, + GroupName = group.Name + }); + + await FlushMessageAsync(message, cancellationToken); + } + else if (operationKind == OperationKind.UpdateResource) + { + if (_beforeGroupName != group.Name) + { + var message = OutgoingMessage.CreateFromContent(new GroupRenamedContent + { + GroupId = group.Id, + BeforeGroupName = _beforeGroupName, + AfterGroupName = group.Name + }); + + await FlushMessageAsync(message, cancellationToken); + } + } + else if (operationKind == OperationKind.DeleteResource) + { + DomainGroup groupToDelete = await GetGroupToDeleteAsync(group.Id, cancellationToken); + + if (groupToDelete != null) + { + foreach (DomainUser user in groupToDelete.Users) + { + var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent + { + UserId = user.Id, + GroupId = group.Id + }); + + await FlushMessageAsync(removeMessage, cancellationToken); + } + } + + var deleteMessage = OutgoingMessage.CreateFromContent(new GroupDeletedContent + { + GroupId = group.Id + }); + + await FlushMessageAsync(deleteMessage, cancellationToken); + } + + foreach (OutgoingMessage nextMessage in _pendingMessages) + { + await FlushMessageAsync(nextMessage, cancellationToken); + } + } + + protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); + + protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + { + return await _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs new file mode 100644 index 0000000000..5b2dc27b7f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + public abstract class MessagingUserDefinition : JsonApiResourceDefinition + { + private readonly DbSet _userSet; + private readonly List _pendingMessages = new List(); + + private string _beforeLoginName; + private string _beforeDisplayName; + + protected MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet) + : base(resourceGraph) + { + _userSet = userSet; + } + + public override Task OnPrepareWriteAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + user.Id = Guid.NewGuid(); + } + else if (operationKind == OperationKind.UpdateResource) + { + _beforeLoginName = user.LoginName; + _beforeDisplayName = user.DisplayName; + } + + return Task.CompletedTask; + } + + public override Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken) + { + if (hasOneRelationship.Property.Name == nameof(DomainUser.Group)) + { + var afterGroupId = (Guid?)rightResourceId?.GetTypedId(); + IMessageContent content = null; + + if (user.Group != null && afterGroupId == null) + { + content = new UserRemovedFromGroupContent + { + UserId = user.Id, + GroupId = user.Group.Id + }; + } + else if (user.Group == null && afterGroupId != null) + { + content = new UserAddedToGroupContent + { + UserId = user.Id, + GroupId = afterGroupId.Value + }; + } + else if (user.Group != null && afterGroupId != null && user.Group.Id != afterGroupId) + { + content = new UserMovedToGroupContent + { + UserId = user.Id, + BeforeGroupId = user.Group.Id, + AfterGroupId = afterGroupId.Value + }; + } + + if (content != null) + { + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); + } + } + + return Task.FromResult(rightResourceId); + } + + protected async Task FinishWriteAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + var message = OutgoingMessage.CreateFromContent(new UserCreatedContent + { + UserId = user.Id, + UserLoginName = user.LoginName, + UserDisplayName = user.DisplayName + }); + + await FlushMessageAsync(message, cancellationToken); + } + else if (operationKind == OperationKind.UpdateResource) + { + if (_beforeLoginName != user.LoginName) + { + var message = OutgoingMessage.CreateFromContent(new UserLoginNameChangedContent + { + UserId = user.Id, + BeforeUserLoginName = _beforeLoginName, + AfterUserLoginName = user.LoginName + }); + + await FlushMessageAsync(message, cancellationToken); + } + + if (_beforeDisplayName != user.DisplayName) + { + var message = OutgoingMessage.CreateFromContent(new UserDisplayNameChangedContent + { + UserId = user.Id, + BeforeUserDisplayName = _beforeDisplayName, + AfterUserDisplayName = user.DisplayName + }); + + await FlushMessageAsync(message, cancellationToken); + } + } + else if (operationKind == OperationKind.DeleteResource) + { + DomainUser userToDelete = await GetUserToDeleteAsync(user.Id, cancellationToken); + + if (userToDelete?.Group != null) + { + var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent + { + UserId = user.Id, + GroupId = userToDelete.Group.Id + }); + + await FlushMessageAsync(removeMessage, cancellationToken); + } + + var deleteMessage = OutgoingMessage.CreateFromContent(new UserDeletedContent + { + UserId = user.Id + }); + + await FlushMessageAsync(deleteMessage, cancellationToken); + } + + foreach (OutgoingMessage nextMessage in _pendingMessages) + { + await FlushMessageAsync(nextMessage, cancellationToken); + } + } + + protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); + + protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + { + return await _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs new file mode 100644 index 0000000000..04edfda455 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class OutboxDbContext : DbContext + { + public DbSet Users { get; set; } + public DbSet Groups { get; set; } + public DbSet OutboxMessages { get; set; } + + public OutboxDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs new file mode 100644 index 0000000000..52245827bc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class OutboxGroupDefinition : MessagingGroupDefinition + { + private readonly DbSet _outboxMessageSet; + + public OutboxGroupDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext) + : base(resourceGraph, dbContext.Users, dbContext.Groups) + { + _outboxMessageSet = dbContext.OutboxMessages; + } + + public override Task OnWritingAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + return FinishWriteAsync(group, operationKind, cancellationToken); + } + + protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + await _outboxMessageSet.AddAsync(message, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs new file mode 100644 index 0000000000..d18b2e649d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + public sealed partial class OutboxTests + { + [Fact] + public async Task Create_group_adds_to_outbox() + { + // Arrange + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + attributes = new + { + name = newGroupName + } + } + }; + + const string route = "/domainGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); + + Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(newGroupId); + content.GroupName.Should().Be(newGroupName); + }); + } + + [Fact] + public async Task Create_group_with_users_adds_to_outbox() + { + // Arrange + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + attributes = new + { + name = newGroupName + }, + relationships = new + { + users = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + } + } + } + }; + + const string route = "/domainGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); + + Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.GroupId.Should().Be(newGroupId); + content1.GroupName.Should().Be(newGroupName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithoutGroup.Id); + content2.GroupId.Should().Be(newGroupId); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithOtherGroup.Id); + content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content3.AfterGroupId.Should().Be(newGroupId); + }); + } + + [Fact] + public async Task Update_group_adds_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId, + attributes = new + { + name = newGroupName + } + } + }; + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + content.BeforeGroupName.Should().Be(existingGroup.Name); + content.AfterGroupName.Should().Be(newGroupName); + }); + } + + [Fact] + public async Task Update_group_with_users_adds_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId, + relationships = new + { + users = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + } + } + } + }; + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Delete_group_adds_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + }); + } + + [Fact] + public async Task Delete_group_with_users_adds_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); + content1.GroupId.Should().Be(existingGroup.StringId); + + var content2 = messages[1].GetContentAs(); + content2.GroupId.Should().Be(existingGroup.StringId); + }); + } + + [Fact] + public async Task Replace_users_in_group_adds_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Add_users_to_group_adds_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); + existingUserWithSameGroup.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Remove_users_from_group_adds_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithSameGroup2.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUserWithSameGroup2.Id); + content.GroupId.Should().Be(existingGroup.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs new file mode 100644 index 0000000000..169c72fdae --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + public sealed partial class OutboxTests + { + [Fact] + public async Task Create_user_adds_to_outbox() + { + // Arrange + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + attributes = new + { + loginName = newLoginName, + displayName = newDisplayName + } + } + }; + + const string route = "/domainUsers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); + responseDocument.SingleData.Attributes["displayName"].Should().Be(newDisplayName); + + Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(newUserId); + content.UserLoginName.Should().Be(newLoginName); + content.UserDisplayName.Should().Be(newDisplayName); + }); + } + + [Fact] + public async Task Create_user_in_group_adds_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newLoginName = _fakers.DomainUser.Generate().LoginName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + attributes = new + { + loginName = newLoginName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + const string route = "/domainUsers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); + responseDocument.SingleData.Attributes["displayName"].Should().BeNull(); + + Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(newUserId); + content1.UserLoginName.Should().Be(newLoginName); + content1.UserDisplayName.Should().BeNull(); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(newUserId); + content2.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Update_user_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + loginName = newLoginName, + displayName = newDisplayName + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); + content1.AfterUserLoginName.Should().Be(newLoginName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content2.AfterUserDisplayName.Should().Be(newDisplayName); + }); + } + + [Fact] + public async Task Update_user_clear_group_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = (object)null + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingUser.Group.Id); + }); + } + + [Fact] + public async Task Update_user_add_to_group_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Update_user_move_to_group_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeGroupId.Should().Be(existingUser.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Delete_user_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + }); + } + + [Fact] + public async Task Delete_user_in_group_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.GroupId.Should().Be(existingUser.Group.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + }); + } + + [Fact] + public async Task Clear_group_from_user_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingUser.Group.Id); + }); + } + + [Fact] + public async Task Assign_group_to_user_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Replace_group_for_user_adds_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.BeforeGroupId.Should().Be(existingUser.Group.Id); + content.AfterGroupId.Should().Be(existingGroup.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs new file mode 100644 index 0000000000..a351cefbfe --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + // Implements the Transactional Outbox Microservices pattern, described at: https://microservices.io/patterns/data/transactional-outbox.html + + public sealed partial class OutboxTests : IClassFixture, OutboxDbContext>> + { + private readonly ExampleIntegrationTestContext, OutboxDbContext> _testContext; + private readonly DomainFakers _fakers = new DomainFakers(); + + public OutboxTests(ExampleIntegrationTestContext, OutboxDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + services.AddResourceDefinition(); + }); + } + + [Fact] + public async Task Does_not_add_to_outbox_on_write_error() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUser = _fakers.DomainUser.Generate(); + + string missingUserId = Guid.NewGuid().ToString(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingGroup, existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUser.StringId + }, + new + { + type = "domainUsers", + id = missingUserId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'domainUsers' with ID '{missingUserId}' in relationship 'users' does not exist."); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().BeEmpty(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs new file mode 100644 index 0000000000..deae0a9fba --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class OutboxUserDefinition : MessagingUserDefinition + { + private readonly DbSet _outboxMessageSet; + + public OutboxUserDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext) + : base(resourceGraph, dbContext.Users) + { + _outboxMessageSet = dbContext.OutboxMessages; + } + + public override Task OnWritingAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + return FinishWriteAsync(user, operationKind, cancellationToken); + } + + protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + await _outboxMessageSet.AddAsync(message, cancellationToken); + } + } +} From f34ba32de1f2a221a08a2b6909996c07d0d3cb7e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 30 Apr 2021 14:02:40 +0200 Subject: [PATCH 11/22] Added tests for serialization callbacks --- .../Serialization/FieldsToSerialize.cs | 7 +- .../Serialization/IFieldsToSerialize.cs | 5 + .../Serialization/RequestDeserializer.cs | 2 +- .../Serialization/ResponseSerializer.cs | 9 +- .../AtomicSerializationHitCounter.cs | 24 + ...micSerializationResourceDefinitionTests.cs | 367 +++++++++ .../Serialization/RecordCompanyDefinition.cs | 38 + ...icSparseFieldSetResourceDefinitionTests.cs | 2 +- .../LyricPermissionProvider.cs | 2 +- .../LyricTextDefinition.cs | 2 +- .../Serialization/AesEncryptionService.cs | 50 ++ .../Serialization/IEncryptionService.cs | 9 + .../Serialization/Scholarship.cs | 23 + .../Serialization/ScholarshipsController.cs | 15 + .../Serialization/SerializationDbContext.cs | 26 + .../Serialization/SerializationFakers.cs | 28 + .../Serialization/SerializationHitCounter.cs | 24 + .../Serialization/SerializationTests.cs | 735 ++++++++++++++++++ .../Serialization/Student.cs | 19 + .../Serialization/StudentDefinition.cs | 40 + .../Serialization/StudentsController.cs | 15 + 21 files changed, 1433 insertions(+), 9 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationHitCounter.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/{ => SparseFieldSets}/AtomicSparseFieldSetResourceDefinitionTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/{ => SparseFieldSets}/LyricPermissionProvider.cs (82%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/{ => SparseFieldSets}/LyricTextDefinition.cs (96%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/AesEncryptionService.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/IEncryptionService.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationHitCounter.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs index 763c41020a..763ee59b6c 100644 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -19,6 +19,9 @@ public class FieldsToSerialize : IFieldsToSerialize private readonly IJsonApiRequest _request; private readonly SparseFieldSetCache _sparseFieldSetCache; + /// + public bool ShouldSerialize => _request.Kind != EndpointKind.Relationship; + public FieldsToSerialize(IResourceContextProvider resourceContextProvider, IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiRequest request) { @@ -35,7 +38,7 @@ public IReadOnlyCollection GetAttributes(Type resourceType) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - if (_request.Kind == EndpointKind.Relationship) + if (!ShouldSerialize) { return Array.Empty(); } @@ -56,7 +59,7 @@ public IReadOnlyCollection GetRelationships(Type resource { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - if (_request.Kind == EndpointKind.Relationship) + if (!ShouldSerialize) { return Array.Empty(); } diff --git a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs index 7f2775c021..682301b040 100644 --- a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs @@ -10,6 +10,11 @@ namespace JsonApiDotNetCore.Serialization /// public interface IFieldsToSerialize { + /// + /// Indicates whether attributes and relationships should be serialized, based on the current endpoint. + /// + bool ShouldSerialize { get; } + /// /// Gets the collection of attributes that are to be serialized for resources of type . /// diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 9ab6a48866..ba4144bb9d 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -64,7 +64,7 @@ public object Deserialize(string body) object instance = DeserializeBody(body); - if (instance is IIdentifiable resource) + if (instance is IIdentifiable resource && _request.Kind != EndpointKind.Relationship) { _resourceDefinitionAccessor.OnDeserialize(resource); } diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index 343de00a60..2cdb641861 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -96,7 +96,7 @@ private string SerializeErrorDocument(ErrorDocument errorDocument) /// internal string SerializeSingle(IIdentifiable resource) { - if (resource != null) + if (resource != null && _fieldsToSerialize.ShouldSerialize) { _resourceDefinitionAccessor.OnSerialize(resource); } @@ -128,9 +128,12 @@ internal string SerializeSingle(IIdentifiable resource) /// internal string SerializeMany(IReadOnlyCollection resources) { - foreach (IIdentifiable resource in resources) + if (_fieldsToSerialize.ShouldSerialize) { - _resourceDefinitionAccessor.OnSerialize(resource); + foreach (IIdentifiable resource in resources) + { + _resourceDefinitionAccessor.OnSerialize(resource); + } } IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationHitCounter.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationHitCounter.cs new file mode 100644 index 0000000000..baff791fbc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationHitCounter.cs @@ -0,0 +1,24 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization +{ + public sealed class AtomicSerializationHitCounter + { + internal int DeserializeCount { get; private set; } + internal int SerializeCount { get; private set; } + + internal void Reset() + { + DeserializeCount = 0; + SerializeCount = 0; + } + + internal void IncrementDeserializeCount() + { + DeserializeCount++; + } + + internal void IncrementSerializeCount() + { + SerializeCount++; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs new file mode 100644 index 0000000000..f7a71cfd6b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -0,0 +1,367 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample.Controllers; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization +{ + public sealed class AtomicSerializationResourceDefinitionTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicSerializationResourceDefinitionTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + + services.AddSingleton(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } + + [Fact] + public async Task Transforms_on_create_resource_with_side_effects() + { + // Arrange + List newCompanies = _fakers.RecordCompany.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + name = newCompanies[0].Name, + countryOfResidence = newCompanies[0].CountryOfResidence + } + } + }, + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + name = newCompanies[1].Name, + countryOfResidence = newCompanies[1].CountryOfResidence + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newCompanies[0].Name.ToUpperInvariant()); + responseDocument.Results[0].SingleData.Attributes["countryOfResidence"].Should().Be(newCompanies[0].CountryOfResidence.ToUpperInvariant()); + + responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newCompanies[1].Name.ToUpperInvariant()); + responseDocument.Results[1].SingleData.Attributes["countryOfResidence"].Should().Be(newCompanies[1].CountryOfResidence.ToUpperInvariant()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.Should().HaveCount(2); + + companiesInDatabase[0].Name.Should().Be(newCompanies[0].Name.ToUpperInvariant()); + companiesInDatabase[0].CountryOfResidence.Should().Be(newCompanies[0].CountryOfResidence); + + companiesInDatabase[1].Name.Should().Be(newCompanies[1].Name.ToUpperInvariant()); + companiesInDatabase[1].CountryOfResidence.Should().Be(newCompanies[1].CountryOfResidence); + }); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(2); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Skips_on_create_resource_with_ToOne_relationship() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Transforms_on_update_resource_with_side_effects() + { + // Arrange + List existingCompanies = _fakers.RecordCompany.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.RecordCompanies.AddRange(existingCompanies); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "recordCompanies", + id = existingCompanies[0].StringId, + attributes = new + { + } + } + }, + new + { + op = "update", + data = new + { + type = "recordCompanies", + id = existingCompanies[1].StringId, + attributes = new + { + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(existingCompanies[0].Name); + responseDocument.Results[0].SingleData.Attributes["countryOfResidence"].Should().Be(existingCompanies[0].CountryOfResidence.ToUpperInvariant()); + + responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(existingCompanies[1].Name); + responseDocument.Results[1].SingleData.Attributes["countryOfResidence"].Should().Be(existingCompanies[1].CountryOfResidence.ToUpperInvariant()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.Should().HaveCount(2); + + companiesInDatabase[0].Name.Should().Be(existingCompanies[0].Name); + companiesInDatabase[0].CountryOfResidence.Should().Be(existingCompanies[0].CountryOfResidence); + + companiesInDatabase[1].Name.Should().Be(existingCompanies[1].Name); + companiesInDatabase[1].CountryOfResidence.Should().Be(existingCompanies[1].CountryOfResidence); + }); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(2); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Skips_on_update_resource_with_ToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_update_ToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs new file mode 100644 index 0000000000..2d899b27b3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class RecordCompanyDefinition : JsonApiResourceDefinition + { + private readonly AtomicSerializationHitCounter _hitCounter; + + public RecordCompanyDefinition(IResourceGraph resourceGraph, AtomicSerializationHitCounter hitCounter) + : base(resourceGraph) + { + _hitCounter = hitCounter; + } + + public override void OnDeserialize(RecordCompany resource) + { + _hitCounter.IncrementDeserializeCount(); + + if (!string.IsNullOrEmpty(resource.Name)) + { + resource.Name = resource.Name.ToUpperInvariant(); + } + } + + public override void OnSerialize(RecordCompany resource) + { + _hitCounter.IncrementSerializeCount(); + + if (!string.IsNullOrEmpty(resource.CountryOfResidence)) + { + resource.CountryOfResidence = resource.CountryOfResidence.ToUpperInvariant(); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs index ba3456584c..b745a39f7b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -12,7 +12,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets { public sealed class AtomicSparseFieldSetResourceDefinitionTests : IClassFixture, OperationsDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs similarity index 82% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs index 07140f6fa1..63620c991a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets { public sealed class LyricPermissionProvider { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs similarity index 96% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs index bf89c79b70..7603fd4588 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class LyricTextDefinition : JsonApiResourceDefinition diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/AesEncryptionService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/AesEncryptionService.cs new file mode 100644 index 0000000000..c772b5c98f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/AesEncryptionService.cs @@ -0,0 +1,50 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class AesEncryptionService : IEncryptionService + { + private static readonly byte[] CryptoKey = Encoding.UTF8.GetBytes("Secret!".PadRight(32, '-')); + + public string Encrypt(string value) + { + using SymmetricAlgorithm cipher = CreateCipher(); + + using ICryptoTransform transform = cipher.CreateEncryptor(); + byte[] plaintext = Encoding.UTF8.GetBytes(value); + byte[] cipherText = transform.TransformFinalBlock(plaintext, 0, plaintext.Length); + + byte[] buffer = new byte[cipher.IV.Length + cipherText.Length]; + Buffer.BlockCopy(cipher.IV, 0, buffer, 0, cipher.IV.Length); + Buffer.BlockCopy(cipherText, 0, buffer, cipher.IV.Length, cipherText.Length); + + return Convert.ToBase64String(buffer); + } + + public string Decrypt(string value) + { + byte[] buffer = Convert.FromBase64String(value); + + using SymmetricAlgorithm cipher = CreateCipher(); + + byte[] initVector = new byte[cipher.IV.Length]; + Buffer.BlockCopy(buffer, 0, initVector, 0, initVector.Length); + cipher.IV = initVector; + + using ICryptoTransform transform = cipher.CreateDecryptor(); + byte[] plainBytes = transform.TransformFinalBlock(buffer, initVector.Length, buffer.Length - initVector.Length); + + return Encoding.UTF8.GetString(plainBytes); + } + + private static SymmetricAlgorithm CreateCipher() + { + var cipher = Aes.Create(); + cipher.Key = CryptoKey; + + return cipher; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/IEncryptionService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/IEncryptionService.cs new file mode 100644 index 0000000000..2b940ef991 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/IEncryptionService.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public interface IEncryptionService + { + string Encrypt(string value); + + string Decrypt(string value); + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs new file mode 100644 index 0000000000..397c8df3ec --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Scholarship : Identifiable + { + [Attr] + public string ProgramName { get; set; } + + [Attr] + public decimal Amount { get; set; } + + [HasMany] + public IList Participants { get; set; } + + [HasOne] + public Student PrimaryContact { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs new file mode 100644 index 0000000000..0a692758ae --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class ScholarshipsController : JsonApiController + { + public ScholarshipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs new file mode 100644 index 0000000000..86377b229c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs @@ -0,0 +1,26 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class SerializationDbContext : DbContext + { + public DbSet Students { get; set; } + public DbSet Scholarships { get; set; } + + public SerializationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(scholarship => scholarship.Participants) + .WithOne(student => student.Scholarship); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationFakers.cs new file mode 100644 index 0000000000..40308802b9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationFakers.cs @@ -0,0 +1,28 @@ +using System; +using Bogus; +using Bogus.Extensions.UnitedStates; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + internal sealed class SerializationFakers : FakerContainer + { + private readonly Lazy> _lazyStudentFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(student => student.Name, faker => faker.Person.FullName) + .RuleFor(student => student.SocialSecurityNumber, faker => faker.Person.Ssn())); + + private readonly Lazy> _lazyScholarshipFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(scholarship => scholarship.ProgramName, faker => faker.Commerce.Department()) + .RuleFor(scholarship => scholarship.Amount, faker => faker.Finance.Amount())); + + public Faker Student => _lazyStudentFaker.Value; + public Faker Scholarship => _lazyScholarshipFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationHitCounter.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationHitCounter.cs new file mode 100644 index 0000000000..27e3018c11 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationHitCounter.cs @@ -0,0 +1,24 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class SerializationHitCounter + { + internal int DeserializeCount { get; private set; } + internal int SerializeCount { get; private set; } + + internal void Reset() + { + DeserializeCount = 0; + SerializeCount = 0; + } + + internal void IncrementDeserializeCount() + { + DeserializeCount++; + } + + internal void IncrementSerializeCount() + { + SerializeCount++; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationTests.cs new file mode 100644 index 0000000000..373867521f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationTests.cs @@ -0,0 +1,735 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class SerializationTests : IClassFixture, SerializationDbContext>> + { + private readonly ExampleIntegrationTestContext, SerializationDbContext> _testContext; + private readonly SerializationFakers _fakers = new SerializationFakers(); + + public SerializationTests(ExampleIntegrationTestContext, SerializationDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } + + [Fact] + public async Task Encrypts_on_get_primary_resources() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + List students = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Students.AddRange(students); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/students"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.ManyData[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber1.Should().Be(students[0].SocialSecurityNumber); + + string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.ManyData[1].Attributes["socialSecurityNumber"]); + socialSecurityNumber2.Should().Be(students[1].SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Encrypts_on_get_primary_resources_with_ToMany_include() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + List scholarships = _fakers.Scholarship.Generate(2); + scholarships[0].Participants = _fakers.Student.Generate(2); + scholarships[1].Participants = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Scholarships.AddRange(scholarships); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/scholarships?include=participants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + responseDocument.Included.Should().HaveCount(4); + + string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber1.Should().Be(scholarships[0].Participants[0].SocialSecurityNumber); + + string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.Included[1].Attributes["socialSecurityNumber"]); + socialSecurityNumber2.Should().Be(scholarships[0].Participants[1].SocialSecurityNumber); + + string socialSecurityNumber3 = encryptionService.Decrypt((string)responseDocument.Included[2].Attributes["socialSecurityNumber"]); + socialSecurityNumber3.Should().Be(scholarships[1].Participants[0].SocialSecurityNumber); + + string socialSecurityNumber4 = encryptionService.Decrypt((string)responseDocument.Included[3].Attributes["socialSecurityNumber"]); + socialSecurityNumber4.Should().Be(scholarships[1].Participants[1].SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(4); + } + + [Fact] + public async Task Encrypts_on_get_primary_resource_by_ID() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Student student = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Students.Add(student); + await dbContext.SaveChangesAsync(); + }); + + string route = "/students/" + student.StringId; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.SingleData.Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(student.SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Encrypts_on_get_secondary_resources() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.Participants = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}/participants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.ManyData[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber1.Should().Be(scholarship.Participants[0].SocialSecurityNumber); + + string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.ManyData[1].Attributes["socialSecurityNumber"]); + socialSecurityNumber2.Should().Be(scholarship.Participants[1].SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Encrypts_on_get_secondary_resource() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.PrimaryContact = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}/primaryContact"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.SingleData.Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(scholarship.PrimaryContact.SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Encrypts_on_get_secondary_resource_with_ToOne_include() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.PrimaryContact = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}?include=primaryContact"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + responseDocument.Included.Should().HaveCount(1); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(scholarship.PrimaryContact.SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Decrypts_on_create_resource() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + string newName = _fakers.Student.Generate().Name; + string newSocialSecurityNumber = _fakers.Student.Generate().SocialSecurityNumber; + + var requestBody = new + { + data = new + { + type = "students", + attributes = new + { + name = newName, + socialSecurityNumber = encryptionService.Encrypt(newSocialSecurityNumber) + } + } + }; + + const string route = "/students"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.SingleData.Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(newSocialSecurityNumber); + + int newStudentId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Student studentInDatabase = await dbContext.Students.FirstWithIdAsync(newStudentId); + + studentInDatabase.SocialSecurityNumber.Should().Be(newSocialSecurityNumber); + }); + + hitCounter.DeserializeCount.Should().Be(1); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Encrypts_on_create_resource_with_included_ToOne_relationship() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Student existingStudent = _fakers.Student.Generate(); + + string newProgramName = _fakers.Scholarship.Generate().ProgramName; + decimal newAmount = _fakers.Scholarship.Generate().Amount; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Students.Add(existingStudent); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "scholarships", + attributes = new + { + programName = newProgramName, + amount = newAmount + }, + relationships = new + { + primaryContact = new + { + data = new + { + type = "students", + id = existingStudent.StringId + } + } + } + } + }; + + const string route = "/scholarships?include=primaryContact"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + + responseDocument.Included.Should().HaveCount(1); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(existingStudent.SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Decrypts_on_update_resource() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Student existingStudent = _fakers.Student.Generate(); + + string newSocialSecurityNumber = _fakers.Student.Generate().SocialSecurityNumber; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Students.Add(existingStudent); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "students", + id = existingStudent.StringId, + attributes = new + { + socialSecurityNumber = encryptionService.Encrypt(newSocialSecurityNumber) + } + } + }; + + string route = "/students/" + existingStudent.StringId; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.SingleData.Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(newSocialSecurityNumber); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Student studentInDatabase = await dbContext.Students.FirstWithIdAsync(existingStudent.Id); + + studentInDatabase.SocialSecurityNumber.Should().Be(newSocialSecurityNumber); + }); + + hitCounter.DeserializeCount.Should().Be(1); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Encrypts_on_update_resource_with_included_ToMany_relationship() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + existingScholarship.Participants = _fakers.Student.Generate(3); + + decimal newAmount = _fakers.Scholarship.Generate().Amount; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(existingScholarship); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "scholarships", + id = existingScholarship.StringId, + attributes = new + { + amount = newAmount + }, + relationships = new + { + participants = new + { + data = new[] + { + new + { + type = "students", + id = existingScholarship.Participants[0].StringId + }, + new + { + type = "students", + id = existingScholarship.Participants[2].StringId + } + } + } + } + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}?include=participants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + responseDocument.Included.Should().HaveCount(2); + + string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber1.Should().Be(existingScholarship.Participants[0].SocialSecurityNumber); + + string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.Included[1].Attributes["socialSecurityNumber"]); + socialSecurityNumber2.Should().Be(existingScholarship.Participants[2].SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Skips_on_get_ToOne_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.PrimaryContact = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}/relationships/primaryContact"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(scholarship.PrimaryContact.StringId); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_get_ToMany_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.Participants = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}/relationships/participants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(scholarship.Participants[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(scholarship.Participants[1].StringId); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_update_ToOne_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + Student existingStudent = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingScholarship, existingStudent); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "students", + id = existingStudent.StringId + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}/relationships/primaryContact"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_set_ToMany_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + List existingStudents = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(existingScholarship); + dbContext.Students.AddRange(existingStudents); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "students", + id = existingStudents[0].StringId + }, + new + { + type = "students", + id = existingStudents[1].StringId + } + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}/relationships/participants"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_add_to_ToMany_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + List existingStudents = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(existingScholarship); + dbContext.Students.AddRange(existingStudents); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "students", + id = existingStudents[0].StringId + }, + new + { + type = "students", + id = existingStudents[1].StringId + } + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}/relationships/participants"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_remove_from_ToMany_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + existingScholarship.Participants = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(existingScholarship); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "students", + id = existingScholarship.Participants[0].StringId + }, + new + { + type = "students", + id = existingScholarship.Participants[1].StringId + } + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}/relationships/participants"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs new file mode 100644 index 0000000000..bcec15bf30 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Student : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public string SocialSecurityNumber { get; set; } + + [HasOne] + public Scholarship Scholarship { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs new file mode 100644 index 0000000000..2552c51031 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs @@ -0,0 +1,40 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class StudentDefinition : JsonApiResourceDefinition + { + private readonly IEncryptionService _encryptionService; + private readonly SerializationHitCounter _hitCounter; + + public StudentDefinition(IResourceGraph resourceGraph, IEncryptionService encryptionService, SerializationHitCounter hitCounter) + : base(resourceGraph) + { + _encryptionService = encryptionService; + _hitCounter = hitCounter; + } + + public override void OnDeserialize(Student resource) + { + _hitCounter.IncrementDeserializeCount(); + + if (!string.IsNullOrEmpty(resource.SocialSecurityNumber)) + { + resource.SocialSecurityNumber = _encryptionService.Decrypt(resource.SocialSecurityNumber); + } + } + + public override void OnSerialize(Student resource) + { + _hitCounter.IncrementSerializeCount(); + + if (!string.IsNullOrEmpty(resource.SocialSecurityNumber)) + { + resource.SocialSecurityNumber = _encryptionService.Encrypt(resource.SocialSecurityNumber); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs new file mode 100644 index 0000000000..0460c84f7f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class StudentsController : JsonApiController + { + public StudentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} From 395886e7d31c1ec444f2cdb9133653e5826edcec Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 30 Apr 2021 14:08:05 +0200 Subject: [PATCH 12/22] Moved existing ModelState validation tests into subfolder --- .../ModelState}/ModelStateDbContext.cs | 2 +- .../ModelState}/ModelStateValidationTests.cs | 2 +- .../ModelState}/NoModelStateValidationTests.cs | 2 +- .../ModelState}/SystemDirectoriesController.cs | 2 +- .../ModelState}/SystemDirectory.cs | 2 +- .../ModelState}/SystemFile.cs | 2 +- .../ModelState}/SystemFilesController.cs | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ModelStateValidation => InputValidation/ModelState}/ModelStateDbContext.cs (92%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ModelStateValidation => InputValidation/ModelState}/ModelStateValidationTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ModelStateValidation => InputValidation/ModelState}/NoModelStateValidationTests.cs (97%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ModelStateValidation => InputValidation/ModelState}/SystemDirectoriesController.cs (85%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ModelStateValidation => InputValidation/ModelState}/SystemDirectory.cs (93%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ModelStateValidation => InputValidation/ModelState}/SystemFile.cs (86%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ModelStateValidation => InputValidation/ModelState}/SystemFilesController.cs (84%) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs similarity index 92% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs index 7e76eb5617..0805cc34f9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs @@ -3,7 +3,7 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ModelStateDbContext : DbContext diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index ac56b7c0fe..a59f88d766 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -8,7 +8,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs similarity index 97% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index cf30a79101..7157e70491 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -7,7 +7,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs similarity index 85% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs index 63ee816e0d..639c319d7c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { public sealed class SystemDirectoriesController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs similarity index 93% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs index d291370ae6..4504dd6e13 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class SystemDirectory : Identifiable diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs similarity index 86% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index 8598695d35..1a66473c8d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class SystemFile : Identifiable diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs similarity index 84% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs index 0a18e248f1..425445b6ad 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { public sealed class SystemFilesController : JsonApiController { From 6f6d1acd294e60232995c260c7f4d860b42e9a46 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 30 Apr 2021 15:43:08 +0200 Subject: [PATCH 13/22] Added stage transition tests --- JsonApiDotNetCore.sln.DotSettings | 1 + .../InputValidation/RequestBody/Workflow.cs | 14 ++ .../RequestBody/WorkflowDbContext.cs | 18 ++ .../RequestBody/WorkflowDefinition.cs | 115 ++++++++++++ .../RequestBody/WorkflowStage.cs | 12 ++ .../RequestBody/WorkflowTests.cs | 173 ++++++++++++++++++ .../RequestBody/WorkflowsController.cs | 16 ++ 7 files changed, 349 insertions(+) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 4b88935aaf..d22ebc6887 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -628,4 +628,5 @@ $left$ = $right$; True True True + True diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs new file mode 100644 index 0000000000..1cd1e3b783 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs @@ -0,0 +1,14 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Workflow : Identifiable + { + [Attr] + public WorkflowStage Stage { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs new file mode 100644 index 0000000000..a54ac87406 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class WorkflowDbContext : DbContext + { + public DbSet Workflows { get; set; } + + public WorkflowDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs new file mode 100644 index 0000000000..7c0b59c53b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class WorkflowDefinition : JsonApiResourceDefinition + { + private static readonly Dictionary> StageTransitionTable = + new Dictionary> + { + [WorkflowStage.Created] = new[] + { + WorkflowStage.InProgress + }, + [WorkflowStage.InProgress] = new[] + { + WorkflowStage.OnHold, + WorkflowStage.Succeeded, + WorkflowStage.Failed, + WorkflowStage.Canceled + }, + [WorkflowStage.OnHold] = new[] + { + WorkflowStage.InProgress, + WorkflowStage.Canceled + } + }; + + private WorkflowStage _previousStage; + + public WorkflowDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public override Task OnPrepareWriteAsync(Workflow resource, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.UpdateResource) + { + _previousStage = resource.Stage; + } + + return Task.CompletedTask; + } + + public override Task OnWritingAsync(Workflow resource, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + AssertHasValidInitialStage(resource); + } + else if (operationKind == OperationKind.UpdateResource && resource.Stage != _previousStage) + { + AssertCanTransitionToStage(_previousStage, resource.Stage); + } + + return Task.CompletedTask; + } + + [AssertionMethod] + private static void AssertHasValidInitialStage(Workflow resource) + { + if (resource.Stage != WorkflowStage.Created) + { + throw new JsonApiException(new Error(HttpStatusCode.UnprocessableEntity) + { + Title = "Invalid workflow stage.", + Detail = $"Initial stage of workflow must be '{WorkflowStage.Created}'.", + Source = + { + Pointer = "/data/attributes/stage" + } + }); + } + } + + [AssertionMethod] + private static void AssertCanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) + { + if (!CanTransitionToStage(fromStage, toStage)) + { + throw new JsonApiException(new Error(HttpStatusCode.UnprocessableEntity) + { + Title = "Invalid workflow stage.", + Detail = $"Cannot transition from '{fromStage}' to '{toStage}'.", + Source = + { + Pointer = "/data/attributes/stage" + } + }); + } + } + + private static bool CanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) + { + if (StageTransitionTable.ContainsKey(fromStage)) + { + ICollection possibleNextStages = StageTransitionTable[fromStage]; + return possibleNextStages.Contains(toStage); + } + + return false; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs new file mode 100644 index 0000000000..67602b4483 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs @@ -0,0 +1,12 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + public enum WorkflowStage + { + Created, + InProgress, + OnHold, + Succeeded, + Failed, + Canceled + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs new file mode 100644 index 0000000000..ec6bfbdeff --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> + { + private readonly ExampleIntegrationTestContext, WorkflowDbContext> _testContext; + + public WorkflowTests(ExampleIntegrationTestContext, WorkflowDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); + } + + [Fact] + public async Task Can_create_in_valid_stage() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workflows", + attributes = new + { + stage = WorkflowStage.Created + } + } + }; + + const string route = "/workflows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + } + + [Fact] + public async Task Cannot_create_in_invalid_stage() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workflows", + attributes = new + { + stage = WorkflowStage.Canceled + } + } + }; + + const string route = "/workflows"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Invalid workflow stage."); + error.Detail.Should().Be("Initial stage of workflow must be 'Created'."); + error.Source.Pointer.Should().Be("/data/attributes/stage"); + } + + [Fact] + public async Task Cannot_transition_to_invalid_stage() + { + // Arrange + var existingWorkflow = new Workflow + { + Stage = WorkflowStage.OnHold + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Workflows.Add(existingWorkflow); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workflows", + id = existingWorkflow.StringId, + attributes = new + { + stage = WorkflowStage.Succeeded + } + } + }; + + string route = "/workflows/" + existingWorkflow.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Invalid workflow stage."); + error.Detail.Should().Be("Cannot transition from 'OnHold' to 'Succeeded'."); + error.Source.Pointer.Should().Be("/data/attributes/stage"); + } + + [Fact] + public async Task Can_transition_to_valid_stage() + { + // Arrange + var existingWorkflow = new Workflow + { + Stage = WorkflowStage.InProgress + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Workflows.Add(existingWorkflow); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workflows", + id = existingWorkflow.StringId, + attributes = new + { + stage = WorkflowStage.Failed + } + } + }; + + string route = "/workflows/" + existingWorkflow.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs new file mode 100644 index 0000000000..8edd74c51c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs @@ -0,0 +1,16 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + public sealed class WorkflowsController : JsonApiController + { + public WorkflowsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} From 723d325c0c154109e99abbcba40166c091cb8572 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 3 May 2021 11:43:00 +0200 Subject: [PATCH 14/22] Corrected method order --- .../TelevisionBroadcastDefinition.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 5a1e1bbc0d..2ccefe3670 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -119,6 +119,16 @@ private bool HasFilterOnArchivedAt(FilterExpression existingFilter) return walker.HasFilterOnArchivedAt; } + public override Task OnPrepareWriteAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.UpdateResource) + { + _storedArchivedAt = broadcast.ArchivedAt; + } + + return base.OnPrepareWriteAsync(broadcast, operationKind, cancellationToken); + } + public override async Task OnWritingAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) { if (operationKind == OperationKind.CreateResource) @@ -179,16 +189,6 @@ private static void AssertIsArchived(TelevisionBroadcast broadcast) } } - public override Task OnPrepareWriteAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) - { - if (operationKind == OperationKind.UpdateResource) - { - _storedArchivedAt = broadcast.ArchivedAt; - } - - return base.OnPrepareWriteAsync(broadcast, operationKind, cancellationToken); - } - private sealed class FilterWalker : QueryExpressionRewriter { public bool HasFilterOnArchivedAt { get; private set; } From 2ef8424233a139adc1f9114fbe77ff66d4e09bcd Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 5 May 2021 22:33:12 +0200 Subject: [PATCH 15/22] Addressed review feedback --- .../Middleware/OperationKind.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 8 ++++---- .../Resources/IResourceDefinition.cs | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/JsonApiDotNetCore/Middleware/OperationKind.cs b/src/JsonApiDotNetCore/Middleware/OperationKind.cs index abd148106f..e3a528f2b0 100644 --- a/src/JsonApiDotNetCore/Middleware/OperationKind.cs +++ b/src/JsonApiDotNetCore/Middleware/OperationKind.cs @@ -1,7 +1,7 @@ namespace JsonApiDotNetCore.Middleware { /// - /// Lists the functional operation kinds from a resource request or an atomic:operations request. + /// Lists the functional operation kinds of a resource request or an atomic:operations request. /// public enum OperationKind { diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index e19ec2ea09..24f835c6df 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -173,7 +173,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r { object rightResources = relationship.GetValue(resourceFromRequest); - object rightResourcesEdited = await EditRelationshipAsync(resourceForDatabase, relationship, rightResources, OperationKind.CreateResource, + object rightResourcesEdited = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightResources, OperationKind.CreateResource, cancellationToken); await UpdateRelationshipAsync(relationship, resourceForDatabase, rightResourcesEdited, collector, cancellationToken); @@ -194,7 +194,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); } - private async Task EditRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightResourceIds, + private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightResourceIds, OperationKind operationKind, CancellationToken cancellationToken) { if (relationship is HasOneAttribute hasOneRelationship) @@ -241,7 +241,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r { object rightResources = relationship.GetValue(resourceFromRequest); - object rightResourcesEdited = await EditRelationshipAsync(resourceFromDatabase, relationship, rightResources, OperationKind.UpdateResource, + object rightResourcesEdited = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightResources, OperationKind.UpdateResource, cancellationToken); AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightResourcesEdited); @@ -392,7 +392,7 @@ public virtual async Task SetRelationshipAsync(TResource primaryResource, object RelationshipAttribute relationship = _targetedFields.Relationships.Single(); object secondaryResourceIdsEdited = - await EditRelationshipAsync(primaryResource, relationship, secondaryResourceIds, OperationKind.SetRelationship, cancellationToken); + await VisitSetRelationshipAsync(primaryResource, relationship, secondaryResourceIds, OperationKind.SetRelationship, cancellationToken); AssertIsNotClearingRequiredRelationship(relationship, primaryResource, secondaryResourceIdsEdited); diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 96baa1db52..6a958066c0 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -158,7 +158,7 @@ public interface IResourceDefinition Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); /// - /// Executes before replacing (overwriting) a to-one relationship. + /// Executes before setting (or clearing) the resource at the right side of a to-one relationship. /// /// Implementing this method enables to perform validations and change , before the relationship is updated. /// @@ -167,10 +167,10 @@ public interface IResourceDefinition /// The original resource retrieved from the underlying data store, that declares . /// /// - /// The to-one relationship being replaced or cleared. + /// The to-one relationship being set. /// /// - /// The replacement resource identifier (or null to clear the relationship), coming from the request. + /// The new resource identifier (or null to clear the relationship), coming from the request. /// /// /// Identifies from which endpoint this method was called. Possible values: , @@ -180,13 +180,13 @@ public interface IResourceDefinition /// Propagates notification that request handling should be canceled. /// /// - /// The replacement resource identifier, or null to clear the relationship. + /// The replacement resource identifier, or null to clear the relationship. Returns by default. /// Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken); /// - /// Executes before replacing (overwriting) a to-many relationship. + /// Executes before setting the resources at the right side of a to-many relationship. This replaces on existing set. /// /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. /// @@ -195,7 +195,7 @@ Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAt /// The original resource retrieved from the underlying data store, that declares . /// /// - /// The to-many relationship being replaced. + /// The to-many relationship being set. /// /// /// The set of resource identifiers to replace any existing set with, coming from the request. @@ -211,7 +211,7 @@ Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasMa OperationKind operationKind, CancellationToken cancellationToken); /// - /// Executes before adding resources to a to-many relationship, as part of a POST relationship request. + /// Executes before adding resources to the right side of a to-many relationship, as part of a POST relationship request. /// /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. /// @@ -232,7 +232,7 @@ Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelati CancellationToken cancellationToken); /// - /// Executes before removing resources from a to-many relationship, as part of a DELETE relationship request. + /// Executes before removing resources from the right side of a to-many relationship, as part of a DELETE relationship request. /// /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. /// From b94bb1b6d1878d1007881f2177fa4ec9783b2e98 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 6 May 2021 18:31:54 +0200 Subject: [PATCH 16/22] Moved resource definition docs under extensibility --- .../{resources => extensibility}/resource-definitions.md | 0 docs/usage/reading/filtering.md | 4 ++-- docs/usage/reading/pagination.md | 2 +- docs/usage/reading/sorting.md | 2 +- docs/usage/reading/sparse-fieldset-selection.md | 2 +- docs/usage/resources/hooks.md | 2 +- docs/usage/toc.md | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename docs/usage/{resources => extensibility}/resource-definitions.md (100%) diff --git a/docs/usage/resources/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md similarity index 100% rename from docs/usage/resources/resource-definitions.md rename to docs/usage/extensibility/resource-definitions.md diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md index 90b03462d8..d3eb325805 100644 --- a/docs/usage/reading/filtering.md +++ b/docs/usage/reading/filtering.md @@ -119,8 +119,8 @@ GET /articles?filter[caption]=tech&filter=expr:equals(caption,'cooking')) HTTP/1 There are multiple ways you can add custom filters: -1. Implementing `IResourceDefinition.OnApplyFilter` (see [here](~/usage/resources/resource-definitions.md#exclude-soft-deleted-resources)) and inject `IRequestQueryStringAccessor`, which works at all depths, but filter operations are constrained to what `FilterExpression` provides -2. Implementing `IResourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters` as [described previously](~/usage/resources/resource-definitions.md#custom-query-string-parameters), which enables the full range of `IQueryable` functionality, but only works on primary endpoints +1. Implementing `IResourceDefinition.OnApplyFilter` (see [here](~/usage/extensibility/resource-definitions.md#exclude-soft-deleted-resources)) and inject `IRequestQueryStringAccessor`, which works at all depths, but filter operations are constrained to what `FilterExpression` provides +2. Implementing `IResourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters` as described [here](~/usage/extensibility/resource-definitions.md#custom-query-string-parameters), which enables the full range of `IQueryable` functionality, but only works on primary endpoints 3. Add an implementation of `IQueryConstraintProvider` to supply additional `FilterExpression`s, which are combined with existing filters using AND operator 4. Override `EntityFrameworkCoreRepository.ApplyQueryLayer` to adapt the `IQueryable` expression just before execution 5. Take a deep dive and plug into reader/parser/tokenizer/visitor/builder for adding additional general-purpose filter operators diff --git a/docs/usage/reading/pagination.md b/docs/usage/reading/pagination.md index 7ab92e2dec..77772288b3 100644 --- a/docs/usage/reading/pagination.md +++ b/docs/usage/reading/pagination.md @@ -22,4 +22,4 @@ GET /api/blogs/1/articles?include=revisions&page[size]=10,revisions:5&page[numbe ## Configuring Default Behavior -You can configure the global default behavior as [described previously](~/usage/options.md#pagination). +You can configure the global default behavior as described [here](~/usage/options.md#pagination). diff --git a/docs/usage/reading/sorting.md b/docs/usage/reading/sorting.md index a6fcb4f771..dfadc325fa 100644 --- a/docs/usage/reading/sorting.md +++ b/docs/usage/reading/sorting.md @@ -52,5 +52,5 @@ This sorts the list of blogs by their captions and included revisions by their p ## Default Sort -See the topic on [Resource Definitions](~/usage/resources/resource-definitions.md) +See the topic on [Resource Definitions](~/usage/extensibility/resource-definitions.md) for overriding the default sort behavior. diff --git a/docs/usage/reading/sparse-fieldset-selection.md b/docs/usage/reading/sparse-fieldset-selection.md index b5510be945..7d90bf9d26 100644 --- a/docs/usage/reading/sparse-fieldset-selection.md +++ b/docs/usage/reading/sparse-fieldset-selection.md @@ -41,4 +41,4 @@ When omitted, you'll get the included resources returned, but without full resou ## Overriding -As a developer, you can force to include and/or exclude specific fields as [described previously](~/usage/resources/resource-definitions.md). +As a developer, you can force to include and/or exclude specific fields as described [here](~/usage/extensibility/resource-definitions.md). diff --git a/docs/usage/resources/hooks.md b/docs/usage/resources/hooks.md index 68295fa44a..e34c4922fd 100644 --- a/docs/usage/resources/hooks.md +++ b/docs/usage/resources/hooks.md @@ -1,7 +1,7 @@ # Resource Hooks -This section covers the usage of **Resource Hooks**, which is a feature of`ResourceHooksDefinition`. See the [ResourceDefinition usage guide](resource-definitions.md) for a general explanation on how to set up a `JsonApiResourceDefinition`. For a quick start, jump right to the [Getting started: most minimal example](#getting-started-most-minimal-example) section. +This section covers the usage of **Resource Hooks**, which is a feature of`ResourceHooksDefinition`. See the [ResourceDefinition usage guide](~/usage/extensibility/resource-definitions.md) for a general explanation on how to set up a `JsonApiResourceDefinition`. For a quick start, jump right to the [Getting started: most minimal example](#getting-started-most-minimal-example) section. > Note: Resource Hooks are an experimental feature and are turned off by default. They are subject to change or be replaced in a future version. diff --git a/docs/usage/toc.md b/docs/usage/toc.md index 82ba96a42f..3e88bfc921 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -1,7 +1,6 @@ # [Resources](resources/index.md) ## [Attributes](resources/attributes.md) ## [Relationships](resources/relationships.md) -## [Resource Definitions](resources/resource-definitions.md) # Reading data ## [Filtering](reading/filtering.md) @@ -24,6 +23,7 @@ # Extensibility ## [Layer Overview](extensibility/layer-overview.md) +## [Resource Definitions](extensibility/resource-definitions.md) ## [Controllers](extensibility/controllers.md) ## [Resource Services](extensibility/services.md) ## [Resource Repositories](extensibility/repositories.md) From d929bcb4d5f83c2411062d49c3a48d61e1b8cd34 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 7 May 2021 12:40:50 +0200 Subject: [PATCH 17/22] Update docs on layer overview and resource definitions. --- docs/usage/extensibility/layer-overview.md | 35 +++++++----- .../extensibility/resource-definitions.md | 55 ++++++++++--------- docs/usage/reading/filtering.md | 2 +- docs/usage/resources/relationships.md | 2 +- 4 files changed, 52 insertions(+), 42 deletions(-) diff --git a/docs/usage/extensibility/layer-overview.md b/docs/usage/extensibility/layer-overview.md index 6f3b622e07..ac2f7aa435 100644 --- a/docs/usage/extensibility/layer-overview.md +++ b/docs/usage/extensibility/layer-overview.md @@ -1,33 +1,42 @@ # Layer Overview -By default, data retrieval is distributed across three layers: +By default, data access flows through the next three layers: ``` JsonApiController (required) + JsonApiResourceService : IResourceService + EntityFrameworkCoreRepository : IResourceRepository +``` -+-- JsonApiResourceService : IResourceService +Aside from these pluggable endpoint-oriented layers, we provide a resource-oriented extensibility point: - +-- EntityFrameworkCoreRepository : IResourceRepository +``` +JsonApiResourceDefinition : IResourceDefinition ``` -Customization can be done at any of these layers. However, it is recommended that you make your customizations at the service or the repository layer when possible, to keep the controllers free of unnecessary logic. -You can use the following as a general rule of thumb for where to put business logic: +Resource definition callbacks are invoked from the built-in resource service/repository layers, as well as from the serializer. +For example, `IResourceDefinition.OnSerialize` is invoked whenever a resource is sent back to the client, irrespective of the endpoint. +Likewise, `IResourceDefinition.OnSetToOneRelationshipAsync` is called from a patch-resource-with-relationships endpoint, as well as from patch-relationship. -- `Controller`: simple validation logic that should result in the return of specific HTTP status codes, such as model validation -- `IResourceService`: advanced business logic and replacement of data access mechanisms -- `IResourceRepository`: custom logic that builds on the Entity Framework Core APIs +Customization can be done at any of these extensibility points. It is usually sufficient to place your business logic in a resource definition, but depending +on your needs, you may want to replace other parts by deriving from the built-in classes and override virtual methods or call their protected base methods. -## Replacing Services +## Replacing injected services -**Note:** If you are using auto-discovery, resource services and repositories will be automatically registered for you. +**Note:** If you are using auto-discovery, then resource services, repositories and resource definitions will be automatically registered for you. -Replacing services and repositories is done on a per-resource basis and can be done through dependency injection in your Startup.cs file. +Replacing built-in services is done on a per-resource basis and can be done through dependency injection in your Startup.cs file. +For convenience, extension methods are provided to register layers on all their implemented interfaces. ```c# // Startup.cs public void ConfigureServices(IServiceCollection services) { - services.AddScoped(); - services.AddScoped>(); + services.AddResourceService(); + services.AddResourceRepository(); + services.AddResourceDefinition(); + + services.AddScoped(); + services.AddScoped(); } ``` diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index e355a30d9a..6d68ec59f5 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -1,11 +1,19 @@ # Resource Definitions -In order to improve the developer experience, we have introduced a type that makes -common modifications to the default API behavior easier. Resource definitions were first introduced in v2.3.4. +_since v2.3.4_ -Resource definitions are resolved from the dependency injection container, so you can inject dependencies in their constructor. +Resource definitions provide a resource-oriented way to handle custom business logic (irrespective of the originating endpoint). -## Customizing query clauses +They are resolved from the dependency injection container, so you can inject dependencies in their constructor. + +**Note:** Prior to the introduction of auto-discovery (in v3), you needed to register the +`ResourceDefinition` on the container yourself: + +```c# +services.AddScoped, ProductResource>(); +``` + +## Customizing queries _since v4.0_ @@ -21,7 +29,7 @@ from Entity Framework Core `IQueryable` execution. There are some cases where you want attributes (or relationships) conditionally excluded from your resource response. For example, you may accept some sensitive data that should only be exposed to administrators after creation. -Note: to exclude attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]`. +Note: to exclude attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]` on a resource class property. ```c# public class UserDefinition : JsonApiResourceDefinition @@ -78,7 +86,7 @@ Content-Type: application/vnd.api+json } ``` -## Default sort order +### Default sort order You can define the default sort order if no `sort` query string parameter is provided. @@ -106,7 +114,7 @@ public class AccountDefinition : JsonApiResourceDefinition } ``` -## Enforce page size +### Enforce page size You may want to enforce pagination on large database tables. @@ -137,9 +145,9 @@ public class AccessLogDefinition : JsonApiResourceDefinition } ``` -## Exclude soft-deleted resources +### Change filters -Soft-deletion sets `IsSoftDeleted` to `true` instead of actually deleting the record, so you may want to always filter them out. +The next example filters out `Account` resources that are suspended. ```c# public class AccountDefinition : JsonApiResourceDefinition @@ -153,23 +161,25 @@ public class AccountDefinition : JsonApiResourceDefinition { var resourceContext = ResourceGraph.GetResourceContext(); - var isSoftDeletedAttribute = + var isSuspendedAttribute = resourceContext.Attributes.Single(account => - account.Property.Name == nameof(Account.IsSoftDeleted)); + account.Property.Name == nameof(Account.IsSuspended)); - var isNotSoftDeleted = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(isSoftDeletedAttribute), + var isNotSuspended = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(isSuspendedAttribute), new LiteralConstantExpression(bool.FalseString)); return existingFilter == null - ? (FilterExpression) isNotSoftDeleted + ? (FilterExpression) isNotSuspended : new LogicalExpression(LogicalOperator.And, - new[] { isNotSoftDeleted, existingFilter }); + new[] { isNotSuspended, existingFilter }); } } ``` -## Block including related resources +### Block including related resources + +In the example below, an error is returned when a user tries to include the manager of an employee. ```c# public class EmployeeDefinition : JsonApiResourceDefinition @@ -196,14 +206,14 @@ public class EmployeeDefinition : JsonApiResourceDefinition } ``` -## Custom query string parameters +### Custom query string parameters _since v3_ You can define additional query string parameters with the LINQ expression that should be used. 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 EF Core functionality. +Note this directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of EF Core operators. But it only works on primary resource endpoints (for example: /articles, but not on /blogs/1/articles or /blogs?include=articles). ```c# @@ -237,12 +247,3 @@ public class ItemDefinition : JsonApiResourceDefinition } } ``` - -## Using Resource Definitions prior to v3 - -Prior to the introduction of auto-discovery, you needed to register the -`ResourceDefinition` on the container yourself: - -```c# -services.AddScoped, ItemResource>(); -``` diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md index d3eb325805..c6b65f9867 100644 --- a/docs/usage/reading/filtering.md +++ b/docs/usage/reading/filtering.md @@ -119,7 +119,7 @@ GET /articles?filter[caption]=tech&filter=expr:equals(caption,'cooking')) HTTP/1 There are multiple ways you can add custom filters: -1. Implementing `IResourceDefinition.OnApplyFilter` (see [here](~/usage/extensibility/resource-definitions.md#exclude-soft-deleted-resources)) and inject `IRequestQueryStringAccessor`, which works at all depths, but filter operations are constrained to what `FilterExpression` provides +1. Implementing `IResourceDefinition.OnApplyFilter` (see [here](~/usage/extensibility/resource-definitions.md#change-filters)) and inject `IRequestQueryStringAccessor`, which works at all depths, but filter operations are constrained to what `FilterExpression` provides 2. Implementing `IResourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters` as described [here](~/usage/extensibility/resource-definitions.md#custom-query-string-parameters), which enables the full range of `IQueryable` functionality, but only works on primary endpoints 3. Add an implementation of `IQueryConstraintProvider` to supply additional `FilterExpression`s, which are combined with existing filters using AND operator 4. Override `EntityFrameworkCoreRepository.ApplyQueryLayer` to adapt the `IQueryable` expression just before execution diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index ac31adf14a..4b386d2587 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -29,7 +29,7 @@ public class Person : Identifiable ## HasManyThrough -Currently, Entity Framework Core [does not support](https://github.com/aspnet/EntityFrameworkCore/issues/1368) many-to-many relationships without a join entity. +Earlier versions of Entity Framework Core (up to v5) [did not support](https://github.com/aspnet/EntityFrameworkCore/issues/1368) many-to-many relationships without a join entity. For this reason, we have decided to fill this gap by allowing applications to declare a relationship as `HasManyThrough`. JsonApiDotNetCore will expose this relationship to the client the same way as any other `HasMany` attribute. However, under the covers it will use the join type and Entity Framework Core's APIs to get and set the relationship. From ef614bc3c421565ef4771a9eac7225c86883e5e0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 7 May 2021 15:08:01 +0200 Subject: [PATCH 18/22] Added documentation with diagrams on resource definition write callbacks; various minor clarifications and corrections elsewhere --- .../resource-definition-create-resource.svg | 3 + .../resource-definition-delete-resource.svg | 3 + .../resource-definition-set-relationship.svg | 3 + .../resource-definition-update-resource.svg | 3 + ...doc-key-blue-lines-blue-blue-blue-blue.svg | 3 + ...key-blue-lines-orange-orange-blue-blue.svg | 3 + ...y-blue-lines-yellow-orange-orange-blue.svg | 3 + ...y-blue-lines-yellow-orange-yellow-blue.svg | 3 + ...ey-green-lines-green-green-green-green.svg | 3 + docs/diagrams/source/glyphs/doc-key-green.svg | 3 + ...doc-key-yellow-lines-yellow-X-yellow-X.svg | 3 + .../diagrams/source/glyphs/doc-key-yellow.svg | 3 + ...doc-nokey-blue-lines-orange-orange-X-X.svg | 3 + ...okey-blue-lines-yellow-orange-orange-X.svg | 3 + ...okey-blue-lines-yellow-orange-yellow-X.svg | 3 + .../diagrams/source/glyphs/doc-nokey-blue.svg | 3 + ...c-nokey-yellow-lines-yellow-X-yellow-X.svg | 3 + docs/diagrams/source/glyphs/equals-sign.svg | 12 ++++ docs/diagrams/source/glyphs/event.svg | 1 + ...ey-green-lines-green-green-green-green.svg | 3 + ...multidoc-key-red-lines-red-red-red-red.svg | 3 + docs/diagrams/source/glyphs/plus-sign.svg | 12 ++++ docs/diagrams/source/glyphs/plus.svg | 7 ++ docs/diagrams/source/glyphs/service-bus.svg | 1 + ...resource-definition-write-callbacks.drawio | 1 + docs/docfx.json | 3 +- docs/usage/extensibility/controllers.md | 3 +- .../extensibility/custom-query-formats.md | 16 ----- docs/usage/extensibility/middleware.md | 5 +- docs/usage/extensibility/query-strings.md | 38 +++++++++++ .../extensibility/resource-definitions.md | 67 +++++++++++++++++++ docs/usage/extensibility/services.md | 14 ++-- docs/usage/resources/index.md | 2 +- docs/usage/toc.md | 2 +- 34 files changed, 215 insertions(+), 26 deletions(-) create mode 100644 docs/diagrams/resource-definition-create-resource.svg create mode 100644 docs/diagrams/resource-definition-delete-resource.svg create mode 100644 docs/diagrams/resource-definition-set-relationship.svg create mode 100644 docs/diagrams/resource-definition-update-resource.svg create mode 100644 docs/diagrams/source/glyphs/doc-key-blue-lines-blue-blue-blue-blue.svg create mode 100644 docs/diagrams/source/glyphs/doc-key-blue-lines-orange-orange-blue-blue.svg create mode 100644 docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-orange-blue.svg create mode 100644 docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-yellow-blue.svg create mode 100644 docs/diagrams/source/glyphs/doc-key-green-lines-green-green-green-green.svg create mode 100644 docs/diagrams/source/glyphs/doc-key-green.svg create mode 100644 docs/diagrams/source/glyphs/doc-key-yellow-lines-yellow-X-yellow-X.svg create mode 100644 docs/diagrams/source/glyphs/doc-key-yellow.svg create mode 100644 docs/diagrams/source/glyphs/doc-nokey-blue-lines-orange-orange-X-X.svg create mode 100644 docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-orange-X.svg create mode 100644 docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-yellow-X.svg create mode 100644 docs/diagrams/source/glyphs/doc-nokey-blue.svg create mode 100644 docs/diagrams/source/glyphs/doc-nokey-yellow-lines-yellow-X-yellow-X.svg create mode 100644 docs/diagrams/source/glyphs/equals-sign.svg create mode 100644 docs/diagrams/source/glyphs/event.svg create mode 100644 docs/diagrams/source/glyphs/multidoc-key-green-lines-green-green-green-green.svg create mode 100644 docs/diagrams/source/glyphs/multidoc-key-red-lines-red-red-red-red.svg create mode 100644 docs/diagrams/source/glyphs/plus-sign.svg create mode 100644 docs/diagrams/source/glyphs/plus.svg create mode 100644 docs/diagrams/source/glyphs/service-bus.svg create mode 100644 docs/diagrams/source/resource-definition-write-callbacks.drawio delete mode 100644 docs/usage/extensibility/custom-query-formats.md create mode 100644 docs/usage/extensibility/query-strings.md diff --git a/docs/diagrams/resource-definition-create-resource.svg b/docs/diagrams/resource-definition-create-resource.svg new file mode 100644 index 0000000000..0336a5fd72 --- /dev/null +++ b/docs/diagrams/resource-definition-create-resource.svg @@ -0,0 +1,3 @@ + + +
On Writing
On Writing
201 Created
201 Created
On Deserialize
On Deserialize
Insert (assigns ID, runs triggers)
Insert (assigns ID, runs triggers)
POST /resources
POST /resources
On Serialize
On Serialize
JsonApiDotNetCore
JsonApiDotNetCore
Get
Get
On Write
Succeeded
On Write...
1
1
2
2
4
4
3
3
5
5
On Prepare Write
On Prepare Write
6
6
8
8
7
7
9
9
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/diagrams/resource-definition-delete-resource.svg b/docs/diagrams/resource-definition-delete-resource.svg new file mode 100644 index 0000000000..9d09789dae --- /dev/null +++ b/docs/diagrams/resource-definition-delete-resource.svg @@ -0,0 +1,3 @@ + + +
On Writing
On Writing
204 No Content
204 No Content
DELETE /resources/1
DELETE /resources/1
Delete
Delete
JsonApiDotNetCore
JsonApiDotNetCore
1
1
2
2
3
3
4
4
On Write
Succeeded
On Write...
5
5
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/diagrams/resource-definition-set-relationship.svg b/docs/diagrams/resource-definition-set-relationship.svg new file mode 100644 index 0000000000..e8e87053ab --- /dev/null +++ b/docs/diagrams/resource-definition-set-relationship.svg @@ -0,0 +1,3 @@ + + +
On Prepare Write
On Prepare Write
204 No Content
204 No Content
Get for update (including targeted relationship)
Get for update (including targeted relationship)
Update
Update
JsonApiDotNetCore
JsonApiDotNetCore
On Set
ToMany
Relationship
On Set...
On Write
Succeeded
On Write...
On Writing
On Writing
PATCH /resources/1/relationships/name
PATCH /resources/1/relationships/name
1
1
3
3
2
2
4
4
6
6
5
5
7
7
8
8
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/diagrams/resource-definition-update-resource.svg b/docs/diagrams/resource-definition-update-resource.svg new file mode 100644 index 0000000000..5bafe1a876 --- /dev/null +++ b/docs/diagrams/resource-definition-update-resource.svg @@ -0,0 +1,3 @@ + + +
On Prepare Write
On Prepare Write
On Writing
On Writing
200 OK
200 OK
On Deserialize
On Deserialize
PATCH /resources/1?include=...
PATCH /resources/1?include=...
Get for update
Get for update
Update (runs triggers)
Update (runs triggers)
On Serialize
On Serialize
JsonApiDotNetCore
JsonApiDotNetCore
Get with includes
Get with includes
On Write
Succeeded
On Write...
1
1
3
3
4
4
5
5
6
6
8
8
7
7
9
9
2
2
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-blue-lines-blue-blue-blue-blue.svg b/docs/diagrams/source/glyphs/doc-key-blue-lines-blue-blue-blue-blue.svg new file mode 100644 index 0000000000..222c833544 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-blue-lines-blue-blue-blue-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-blue-lines-orange-orange-blue-blue.svg b/docs/diagrams/source/glyphs/doc-key-blue-lines-orange-orange-blue-blue.svg new file mode 100644 index 0000000000..c4eed0c97f --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-blue-lines-orange-orange-blue-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-orange-blue.svg b/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-orange-blue.svg new file mode 100644 index 0000000000..5194fdbf77 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-orange-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-yellow-blue.svg b/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-yellow-blue.svg new file mode 100644 index 0000000000..ac1ef400dd --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-yellow-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-green-lines-green-green-green-green.svg b/docs/diagrams/source/glyphs/doc-key-green-lines-green-green-green-green.svg new file mode 100644 index 0000000000..dd2fa2512c --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-green-lines-green-green-green-green.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-green.svg b/docs/diagrams/source/glyphs/doc-key-green.svg new file mode 100644 index 0000000000..73640a34e1 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-green.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-yellow-lines-yellow-X-yellow-X.svg b/docs/diagrams/source/glyphs/doc-key-yellow-lines-yellow-X-yellow-X.svg new file mode 100644 index 0000000000..eb9fee9311 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-yellow-lines-yellow-X-yellow-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-yellow.svg b/docs/diagrams/source/glyphs/doc-key-yellow.svg new file mode 100644 index 0000000000..49d3ddb9f0 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-yellow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-blue-lines-orange-orange-X-X.svg b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-orange-orange-X-X.svg new file mode 100644 index 0000000000..6b4e27765e --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-orange-orange-X-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-orange-X.svg b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-orange-X.svg new file mode 100644 index 0000000000..6e8b794420 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-orange-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-yellow-X.svg b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-yellow-X.svg new file mode 100644 index 0000000000..e9062e5ec5 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-yellow-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-blue.svg b/docs/diagrams/source/glyphs/doc-nokey-blue.svg new file mode 100644 index 0000000000..9e3ce76580 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-yellow-lines-yellow-X-yellow-X.svg b/docs/diagrams/source/glyphs/doc-nokey-yellow-lines-yellow-X-yellow-X.svg new file mode 100644 index 0000000000..62889a851d --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-yellow-lines-yellow-X-yellow-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/equals-sign.svg b/docs/diagrams/source/glyphs/equals-sign.svg new file mode 100644 index 0000000000..31d635b71a --- /dev/null +++ b/docs/diagrams/source/glyphs/equals-sign.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/docs/diagrams/source/glyphs/event.svg b/docs/diagrams/source/glyphs/event.svg new file mode 100644 index 0000000000..ae3c88ab43 --- /dev/null +++ b/docs/diagrams/source/glyphs/event.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/multidoc-key-green-lines-green-green-green-green.svg b/docs/diagrams/source/glyphs/multidoc-key-green-lines-green-green-green-green.svg new file mode 100644 index 0000000000..7ab3a41679 --- /dev/null +++ b/docs/diagrams/source/glyphs/multidoc-key-green-lines-green-green-green-green.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/multidoc-key-red-lines-red-red-red-red.svg b/docs/diagrams/source/glyphs/multidoc-key-red-lines-red-red-red-red.svg new file mode 100644 index 0000000000..a8c7a7b5e9 --- /dev/null +++ b/docs/diagrams/source/glyphs/multidoc-key-red-lines-red-red-red-red.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/plus-sign.svg b/docs/diagrams/source/glyphs/plus-sign.svg new file mode 100644 index 0000000000..ae709c4883 --- /dev/null +++ b/docs/diagrams/source/glyphs/plus-sign.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/docs/diagrams/source/glyphs/plus.svg b/docs/diagrams/source/glyphs/plus.svg new file mode 100644 index 0000000000..bfcce5dda3 --- /dev/null +++ b/docs/diagrams/source/glyphs/plus.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/service-bus.svg b/docs/diagrams/source/glyphs/service-bus.svg new file mode 100644 index 0000000000..d67b837f25 --- /dev/null +++ b/docs/diagrams/source/glyphs/service-bus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/diagrams/source/resource-definition-write-callbacks.drawio b/docs/diagrams/source/resource-definition-write-callbacks.drawio new file mode 100644 index 0000000000..391f538389 --- /dev/null +++ b/docs/diagrams/source/resource-definition-write-callbacks.drawio @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/docs/docfx.json b/docs/docfx.json index 3a67909683..6acc17ce7a 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -32,12 +32,11 @@ ], "resource": [ { - "files": [ "images/**" ] + "files": [ "diagrams/*.svg" ] } ], "overwrite": [ { - "files": [ "apidoc/**.md" ], "exclude": [ "obj/**", "_site/**" ] } ], diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index f46c9c108e..1f9de7a473 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -54,7 +54,8 @@ public class ArticlesController : BaseJsonApiController
} [HttpGet("{id}")] - public override async Task GetAsync(int id, CancellationToken cancellationToken) + public override async Task GetAsync(int id, + CancellationToken cancellationToken) { return await base.GetAsync(id, cancellationToken); } diff --git a/docs/usage/extensibility/custom-query-formats.md b/docs/usage/extensibility/custom-query-formats.md deleted file mode 100644 index 2653682fe6..0000000000 --- a/docs/usage/extensibility/custom-query-formats.md +++ /dev/null @@ -1,16 +0,0 @@ -# Custom QueryString parameters - -For information on the built-in query string parameters, see the documentation for them. -In order to add parsing of custom query string parameters, you can implement the `IQueryStringParameterReader` interface and inject it. - -```c# -public class YourQueryStringParameterReader : IQueryStringParameterReader -{ - // ... -} -``` - -```c# -services.AddScoped(); -services.AddScoped(sp => sp.GetService()); -``` diff --git a/docs/usage/extensibility/middleware.md b/docs/usage/extensibility/middleware.md index 7cbadd1442..9be350250a 100644 --- a/docs/usage/extensibility/middleware.md +++ b/docs/usage/extensibility/middleware.md @@ -1,6 +1,9 @@ # Middleware -It is possible to replace JsonApiDotNetCore middleware components by configuring the IoC container and by configuring `MvcOptions`. +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`. ## Configuring the IoC container diff --git a/docs/usage/extensibility/query-strings.md b/docs/usage/extensibility/query-strings.md new file mode 100644 index 0000000000..1411717ffd --- /dev/null +++ b/docs/usage/extensibility/query-strings.md @@ -0,0 +1,38 @@ +# Query string parameters + +The parsing of built-in query string parameters is done by types that implement `IQueryStringParameterReader`, which accepts the incoming parameter value. +Those types also implement `IQueryConstraintProvider`, which they use to expose the parse result. + +The parse result consists of an expression in a scope. For example: + + +``` +?filter[articles]=lessThan(price,'1.23') +``` + +has expression `lessThan(price,'1.23')` in scope `articles`. + +For more information on the various built-in query string parameters, see the documentation for them. + +## Custom query string parameters + +When using Entity Framework Core, resource definitions provide a concise syntax to bind a LINQ expression to a query string parameter. +See [here](~/usage/extensibility/resource-definitions.md#custom-query-string-parameters) for details. + +## Custom query string parsing + +In order to add parsing of custom query string parameters, you can implement the `IQueryStringParameterReader` interface and register your reader. + +```c# +public class YourQueryStringParameterReader : IQueryStringParameterReader +{ + // ... +} +``` + +```c# +services.AddScoped(); +services.AddScoped(sp => sp.GetService()); +``` + +Now you can inject your custom reader in resource services, repositories, resource definitions etc. diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index 6d68ec59f5..e278c04fa9 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -247,3 +247,70 @@ public class ItemDefinition : JsonApiResourceDefinition } } ``` + +## Handling resource changes + +_since v4.2_ + +Without going into too much details, the diagrams below demonstrate a few scenarios where custom code interacts with write operations. +Click on a diagram to open it full-size in a new window. + +### Create resource + + + + + +1. User sends request to create a resource +2. An empty resource instance is created +3. Developer sets default values for attribute 1 and 2 +4. Attribute 1 and 3 from incoming request are copied into default instance +5. Developer overwrites attribute 3 +6. Row is inserted in database +7. Developer sends notification to service bus +8. The new resource is fetched +9. Resource is sent back to the user + +### Update Resource + + + + + +1. User sends request to update resource with ID 1 +2. Existing resource is fetched from database +3. Developer changes attribute 1 and 2 +4. Attribute 1 and 3 from incoming request are copied into fetched instance +5. Developer overwrites attribute 3 +6. Row is updated in database +7. Developer sends notification to service bus +8. The resource is fetched, along with requested includes +9. Resource with includes is sent back to the user + +### Delete Resource + + + + + +1. User sends request to delete resource with ID 1 +2. Developer runs custom validation logic +3. Row is deleted from database +4. Developer sends notification to service bus +5. Success status is sent back to user + +### Set Relationship + + + + + +1. User sends request to assign two resources (green) to relationship 'name' (black) on resource 1 (yellow) +2. Existing resource (blue) with related resources (red) is fetched from database +3. Developer changes attributes (not shown in diagram for brevity) +4. Developer removes one resource from the to-be-assigned set (green) +5. Existing resources in relationship (red) are replaced with resource from previous step (green) +6. Developer overwrites attributes (not shown in diagram for brevity) +7. Resource and relationship are updated in database +8. Developer sends notification to service bus +9. Success status is sent back to user diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index 7880189eee..7233756062 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -102,14 +102,20 @@ IResourceService +-- ICreateService | POST / | + +-- IUpdateService + | PATCH /{id} + | +-- IDeleteService | DELETE /{id} | - +-- IUpdateService - | PATCH /{id} + +-- IAddToRelationshipService + | POST /{id}/relationships/{relationship} + | + +-- ISetRelationshipService + | PATCH /{id}/relationships/{relationship} | - +-- IUpdateRelationshipService - PATCH /{id}/relationships/{relationship} + +-- IRemoveFromRelationshipService + DELETE /{id}/relationships/{relationship} ``` In order to take advantage of these interfaces you first need to register the service for each implemented interface. diff --git a/docs/usage/resources/index.md b/docs/usage/resources/index.md index ad164d82d4..29f510e543 100644 --- a/docs/usage/resources/index.md +++ b/docs/usage/resources/index.md @@ -29,7 +29,7 @@ you can override the virtual property. public class Person : Identifiable { [Key] - [Column("person_id")] + [Column("PersonID")] public override int Id { get; set; } } ``` diff --git a/docs/usage/toc.md b/docs/usage/toc.md index 3e88bfc921..bcb76c2038 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -28,4 +28,4 @@ ## [Resource Services](extensibility/services.md) ## [Resource Repositories](extensibility/repositories.md) ## [Middleware](extensibility/middleware.md) -## [Custom Query Formats](extensibility/custom-query-formats.md) +## [Query Strings](extensibility/query-strings.md) From f30927c9e7fefdb9422bc8b5e0ce0ccff05d1152 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 13 May 2021 00:01:22 +0200 Subject: [PATCH 19/22] Corrected doc-comments --- src/JsonApiDotNetCore/Resources/IResourceDefinition.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 6a958066c0..2252100055 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -164,7 +164,8 @@ public interface IResourceDefinition /// ///
/// - /// The original resource retrieved from the underlying data store, that declares . + /// The original resource retrieved from the underlying data store. Its type, , declares + /// . /// /// /// The to-one relationship being set. @@ -192,7 +193,8 @@ Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAt /// ///
/// - /// The original resource retrieved from the underlying data store, that declares . + /// The original resource retrieved from the underlying data store. Its type, , declares + /// . /// /// /// The to-many relationship being set. @@ -238,7 +240,8 @@ Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelati /// ///
/// - /// The original resource retrieved from the underlying data store, that declares . + /// The original resource retrieved from the underlying data store. Its type, , declares + /// . /// /// /// The to-many relationship being removed from. From c4e5dfca26076f5bed65abbbce2b49d7495b9e8f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 13 May 2021 00:06:53 +0200 Subject: [PATCH 20/22] Reworded side-load-and-attach --- src/JsonApiDotNetCore/Resources/IResourceDefinition.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 2252100055..366cf9aa87 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -140,7 +140,8 @@ public interface IResourceDefinition /// copied into it. /// /// - /// For POST resource requests, this method is typically used to assign property default values or to side-load-and-attach required relationships. + /// For POST resource requests, this method is typically used to assign property default values or to set required relationships by side-loading the + /// related resources and linking them. /// ///
/// From e8e70265df2355e7e309072114ade1fbd6170219 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 14 May 2021 18:24:28 +0200 Subject: [PATCH 21/22] Added Get Relationship tests --- .../Archiving/ArchiveTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs index 3d9876aeaf..b7700f5ffd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -349,6 +349,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); } + [Fact] + public async Task Get_ToMany_relationship_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}/relationships/broadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + } + + [Fact] + public async Task Get_ToMany_relationship_with_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}/relationships/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.ManyData[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + } + [Fact] public async Task Can_create_unarchived_resource() { From 3bd29b5eebf12c164c0a1bf41e5cba54b5a95489 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 18 May 2021 15:41:15 +0200 Subject: [PATCH 22/22] Addressed review feedback --- docs/usage/resources/relationships.md | 18 ++++++++++++--- docs/usage/routing.md | 6 +++++ .../Resources/IResourceDefinition.cs | 15 +++++++------ .../OutboxTests.Group.cs | 18 +++++++-------- .../OutboxTests.User.cs | 22 +++++++++---------- .../MultiTenancy/MultiTenancyTests.cs | 6 ++--- 6 files changed, 52 insertions(+), 33 deletions(-) diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 4b386d2587..2d515872e9 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -1,7 +1,10 @@ # Relationships -In order for navigation properties to be identified in the model, -they should be labeled with the appropriate attribute (either `HasOne`, `HasMany` or `HasManyThrough`). +A relationship is a named link between two resource types, including a direction. +They are similar to [navigation properties in Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/modeling/relationships). + +Relationships come in three flavors: to-one, to-many and many-to-many. +The left side of a relationship is where the relationship is declared, the right side is the resource type it points to. ## HasOne @@ -15,6 +18,9 @@ public class TodoItem : Identifiable } ``` +The left side of this relationship is of type `TodoItem` (public name: "todoItems") and the right side is of type `Person` (public name: "persons"). + + ## HasMany This exposes a to-many relationship. @@ -27,11 +33,14 @@ public class Person : Identifiable } ``` +The left side of this relationship is of type `Person` (public name: "persons") and the right side is of type `TodoItem` (public name: "todoItems"). + + ## HasManyThrough Earlier versions of Entity Framework Core (up to v5) [did not support](https://github.com/aspnet/EntityFrameworkCore/issues/1368) many-to-many relationships without a join entity. For this reason, we have decided to fill this gap by allowing applications to declare a relationship as `HasManyThrough`. -JsonApiDotNetCore will expose this relationship to the client the same way as any other `HasMany` attribute. +JsonApiDotNetCore will expose this relationship to the client the same way as any other `HasMany` relationship. However, under the covers it will use the join type and Entity Framework Core's APIs to get and set the relationship. ```c# @@ -49,6 +58,9 @@ public class Article : Identifiable } ``` +The left side of this relationship is of type `Article` (public name: "articles") and the right side is of type `Tag` (public name: "tags"). + + ## Name There are two ways the exposed relationship name is determined: diff --git a/docs/usage/routing.md b/docs/usage/routing.md index 10a556173b..9d4ebe5853 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -1,5 +1,11 @@ # Routing +An endpoint URL provides access to a resource or a relationship. Resource endpoints are divided into: +- Primary endpoints, for example: "/articles" and "/articles/1". +- Secondary endpoints, for example: "/articles/1/author" and "/articles/1/comments". + +In the relationship endpoint "/articles/1/relationships/comments", "articles" is the left side of the relationship and "comments" the right side. + ## Namespacing and Versioning URLs You can add a namespace to all URLs by specifying it in ConfigureServices. diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 366cf9aa87..8664d98914 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -165,8 +165,8 @@ public interface IResourceDefinition /// /// /// - /// The original resource retrieved from the underlying data store. Its type, , declares - /// . + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . /// /// /// The to-one relationship being set. @@ -194,8 +194,8 @@ Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAt /// /// /// - /// The original resource retrieved from the underlying data store. Its type, , declares - /// . + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . /// /// /// The to-many relationship being set. @@ -220,7 +220,8 @@ Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasMa /// /// /// - /// Identifier of the resource that declares . + /// Identifier of the left resource. The indication "left" specifies that is declared on + /// . /// /// /// The to-many relationship being added to. @@ -241,8 +242,8 @@ Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelati /// /// /// - /// The original resource retrieved from the underlying data store. Its type, , declares - /// . + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . /// /// /// The to-many relationship being removed from. diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index d18b2e649d..a3bf1ad6b1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -16,7 +16,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Transacti public sealed partial class OutboxTests { [Fact] - public async Task Create_group_adds_to_outbox() + public async Task Create_group_writes_to_outbox() { // Arrange string newGroupName = _fakers.DomainGroup.Generate().Name; @@ -63,7 +63,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Create_group_with_users_adds_to_outbox() + public async Task Create_group_with_users_writes_to_outbox() { // Arrange DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); @@ -145,7 +145,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Update_group_adds_to_outbox() + public async Task Update_group_writes_to_outbox() { // Arrange DomainGroup existingGroup = _fakers.DomainGroup.Generate(); @@ -195,7 +195,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Update_group_with_users_adds_to_outbox() + public async Task Update_group_with_users_writes_to_outbox() { // Arrange DomainGroup existingGroup = _fakers.DomainGroup.Generate(); @@ -282,7 +282,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Delete_group_adds_to_outbox() + public async Task Delete_group_writes_to_outbox() { // Arrange DomainGroup existingGroup = _fakers.DomainGroup.Generate(); @@ -315,7 +315,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Delete_group_with_users_adds_to_outbox() + public async Task Delete_group_with_users_writes_to_outbox() { // Arrange DomainGroup existingGroup = _fakers.DomainGroup.Generate(); @@ -353,7 +353,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Replace_users_in_group_adds_to_outbox() + public async Task Replace_users_in_group_writes_to_outbox() { // Arrange DomainGroup existingGroup = _fakers.DomainGroup.Generate(); @@ -429,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Add_users_to_group_adds_to_outbox() + public async Task Add_users_to_group_writes_to_outbox() { // Arrange DomainGroup existingGroup = _fakers.DomainGroup.Generate(); @@ -493,7 +493,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Remove_users_from_group_adds_to_outbox() + public async Task Remove_users_from_group_writes_to_outbox() { // Arrange DomainGroup existingGroup = _fakers.DomainGroup.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs index 169c72fdae..599d99d2a1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -16,7 +16,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Transacti public sealed partial class OutboxTests { [Fact] - public async Task Create_user_adds_to_outbox() + public async Task Create_user_writes_to_outbox() { // Arrange string newLoginName = _fakers.DomainUser.Generate().LoginName; @@ -67,7 +67,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Create_user_in_group_adds_to_outbox() + public async Task Create_user_in_group_writes_to_outbox() { // Arrange DomainGroup existingGroup = _fakers.DomainGroup.Generate(); @@ -135,7 +135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Update_user_adds_to_outbox() + public async Task Update_user_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); @@ -192,7 +192,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Update_user_clear_group_adds_to_outbox() + public async Task Update_user_clear_group_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); @@ -254,7 +254,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Update_user_add_to_group_adds_to_outbox() + public async Task Update_user_add_to_group_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); @@ -320,7 +320,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Update_user_move_to_group_adds_to_outbox() + public async Task Update_user_move_to_group_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); @@ -389,7 +389,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Delete_user_adds_to_outbox() + public async Task Delete_user_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); @@ -422,7 +422,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Delete_user_in_group_adds_to_outbox() + public async Task Delete_user_in_group_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); @@ -460,7 +460,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Clear_group_from_user_adds_to_outbox() + public async Task Clear_group_from_user_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); @@ -500,7 +500,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Assign_group_to_user_adds_to_outbox() + public async Task Assign_group_to_user_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); @@ -544,7 +544,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Replace_group_for_user_adds_to_outbox() + public async Task Replace_group_for_user_writes_to_outbox() { // Arrange DomainUser existingUser = _fakers.DomainUser.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs index d1a42986d0..39db753277 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -41,7 +41,7 @@ public MultiTenancyTests(ExampleIntegrationTestContext shops = _fakers.WebShop.Generate(2); @@ -68,7 +68,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Filter_on_primary_resources_excludes_other_tenants() + public async Task Filter_on_primary_resources_hides_other_tenants() { // Arrange List shops = _fakers.WebShop.Generate(2); @@ -98,7 +98,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_primary_resources_with_include_excludes_other_tenants() + public async Task Get_primary_resources_with_include_hides_other_tenants() { // Arrange List shops = _fakers.WebShop.Generate(2);