From cd5ea5a9271e8c9770a7db851294a611506b26a5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Nov 2020 10:00:46 +0100 Subject: [PATCH 01/24] Enhancements in create/update/delete Continuation of #851. --- .../JsonApiDeserializerBenchmarks.cs | 4 +- .../JsonApiSerializerBenchmarks.cs | 2 +- .../Controllers/TodoItemsCustomController.cs | 7 +- .../Controllers/TodoItemsTestController.cs | 4 +- .../Data/AppDbContext.cs | 24 +- .../Models/ArticleTag.cs | 16 - .../Models/IdentifiableArticleTag.cs | 18 + .../Models/NonJsonApiResource.cs | 7 - .../Models/Passport.cs | 2 +- .../JsonApiDotNetCoreExample/Models/Person.cs | 3 - .../JsonApiDotNetCoreExample/Models/Tag.cs | 7 - .../Models/TagColor.cs | 9 + .../Models/TodoItem.cs | 23 - .../Models/TodoItemCollection.cs | 2 - .../Services/CustomArticleService.cs | 9 +- .../Startups/Startup.cs | 4 +- .../Repositories/DbContextARepository.cs | 10 +- .../Repositories/DbContextBRepository.cs | 10 +- .../Services/WorkItemService.cs | 17 +- .../Controllers/ReportsController.cs | 4 +- .../ApplicationBuilderExtensions.cs | 2 +- ...ips.cs => IInverseRelationshipResolver.cs} | 9 +- .../Configuration/IJsonApiOptions.cs | 2 +- .../Configuration/IRelatedIdMapper.cs | 18 - ...hips.cs => InverseRelationshipResolver.cs} | 6 +- .../JsonApiApplicationBuilder.cs | 81 +- .../Configuration/JsonApiOptions.cs | 5 - .../Configuration/RelatedIdMapper.cs | 9 - .../Configuration/ResourceGraph.cs | 10 +- .../Configuration/ResourceGraphBuilder.cs | 6 +- .../Configuration/ServiceDiscoveryFacade.cs | 20 +- .../Controllers/BaseJsonApiController.cs | 139 ++- .../Controllers/JsonApiCommandController.cs | 16 +- .../Controllers/JsonApiController.cs | 48 +- .../Controllers/JsonApiQueryController.cs | 10 +- .../Errors/InvalidRequestBodyException.cs | 10 +- .../Errors/MissingResourceInRelationship.cs | 18 + .../Errors/RelationshipNotFoundException.cs | 4 +- .../Errors/ResourceAlreadyExistsException.cs | 20 + .../Errors/ResourceIdIsReadOnlyException.cs | 19 + .../Errors/ResourceNotFoundException.cs | 11 +- .../SecondaryResourcesNotFoundException.cs | 32 + .../ToManyRelationshipRequiredException.cs | 20 + .../Internal/Execution/HookExecutorHelper.cs | 29 +- .../Hooks/Internal/Execution/ResourceHook.cs | 6 +- .../Hooks/Internal/IResourceHookExecutor.cs | 2 +- .../Internal/IResourceHookExecutorFacade.cs | 58 + .../NeverResourceHookExecutorFacade.cs | 95 ++ .../Hooks/Internal/ResourceHookExecutor.cs | 21 +- .../Internal/ResourceHookExecutorFacade.cs | 141 +++ .../Hooks/Internal/Traversal/ChildNode.cs | 6 +- .../Hooks/Internal/Traversal/IResourceNode.cs | 3 +- .../Internal/Traversal/RelationshipProxy.cs | 5 +- .../Hooks/Internal/Traversal/RootNode.cs | 2 +- .../Middleware/ExceptionHandler.cs | 6 + .../Expressions/EqualsAnyOfExpression.cs | 5 + .../Repositories/DataStoreUpdateException.cs | 16 + .../Repositories/DbContextExtensions.cs | 67 +- .../EntityFrameworkCoreRepository.cs | 633 ++++++---- .../IRepositoryRelationshipUpdateHelper.cs | 26 - .../Repositories/IResourceRepository.cs | 20 +- .../IResourceRepositoryAccessor.cs | 19 + .../Repositories/IResourceWriteRepository.cs | 28 +- .../Internal/ThroughEntitiesFilter.cs | 82 ++ .../MemoryLeakDetectionBugRewriter.cs | 59 + .../RepositoryRelationshipUpdateHelper.cs | 128 -- .../ResourceRepositoryAccessor.cs | 52 + .../Repositories/SafeTransactionProxy.cs | 68 -- .../Annotations/HasManyThroughAttribute.cs | 32 +- .../Resources/Annotations/HasOneAttribute.cs | 46 - .../Annotations/RelationshipAttribute.cs | 28 +- .../Resources/IResourceChangeTracker.cs | 15 +- .../Resources/IResourceFactory.cs | 7 +- .../Resources/ITargetedFields.cs | 8 +- .../Resources/IdentifiableComparer.cs | 2 +- .../Resources/IdentifiableExtensions.cs | 22 + .../Resources/ResourceChangeTracker.cs | 10 +- .../Resources/ResourceFactory.cs | 23 +- .../Resources/TargetedFields.cs | 4 +- .../Serialization/BaseDeserializer.cs | 201 +-- .../Serialization/Building/ILinkBuilder.cs | 2 + .../Building/IncludedResourceObjectBuilder.cs | 6 +- .../Serialization/Building/LinkBuilder.cs | 10 + .../Building/ResourceObjectBuilder.cs | 25 +- .../Client/Internal/IRequestSerializer.cs | 4 +- .../Client/Internal/RequestSerializer.cs | 24 +- .../Client/Internal/ResponseDeserializer.cs | 6 +- .../Serialization/FieldsToSerialize.cs | 15 +- .../Serialization/IResponseSerializer.cs | 13 - .../Serialization/JsonApiReader.cs | 200 +-- .../JsonApiSerializationException.cs | 20 + .../Serialization/RequestDeserializer.cs | 26 +- .../Serialization/ResponseSerializer.cs | 15 +- .../ResponseSerializerFactory.cs | 7 +- .../Services/AsyncCollectionExtensions.cs | 28 + .../Services/GetResourcesByIds.cs | 77 ++ .../Services/IAddToRelationshipService.cs | 22 + .../Services/ICreateService.cs | 2 +- .../Services/IGetRelationshipService.cs | 2 +- .../Services/IGetResourcesByIds.cs | 21 + .../IRemoveFromRelationshipService.cs | 22 + .../Services/IResourceCommandService.cs | 10 +- .../Services/ISetRelationshipService.cs | 22 + .../Services/IUpdateRelationshipService.cs | 20 - .../Services/IUpdateService.cs | 3 +- .../Services/JsonApiResourceService.cs | 513 +++++--- src/JsonApiDotNetCore/TypeHelper.cs | 10 +- .../ServiceDiscoveryFacadeTests.cs | 17 +- .../EntityFrameworkCoreRepositoryTests.cs | 13 +- .../Acceptance/InjectableResourceTests.cs | 4 +- .../Acceptance/KebabCaseFormatterTests.cs | 6 +- .../Acceptance/ManyToManyTests.cs | 480 +------- .../ResourceDefinitionTests.cs | 40 +- .../Acceptance/Spec/CreatingDataTests.cs | 392 ------ ...CreatingDataWithClientGeneratedIdsTests.cs | 62 - .../Acceptance/Spec/DeletingDataTests.cs | 57 - .../Spec/DisableQueryAttributeTests.cs | 4 +- .../Spec/FetchingRelationshipsTests.cs | 9 +- .../Spec/FunctionalTestCollection.cs | 5 +- .../Spec/ResourceTypeMismatchTests.cs | 108 -- .../Acceptance/Spec/UpdatingDataTests.cs | 485 -------- .../Spec/UpdatingRelationshipsTests.cs | 844 ++----------- .../Acceptance/TestFixture.cs | 3 +- .../Acceptance/TodoItemControllerTests.cs | 88 +- .../FakeLoggerFactory.cs | 18 +- .../IntegrationTestContext.cs | 4 +- .../IntegrationTests/CompositeKeys/Car.cs | 44 + .../CompositeKeys/CarExpressionRewriter.cs | 169 +++ .../CompositeKeys/CarRepository.cs | 56 + .../CompositeKeys/CarsController.cs | 16 + .../CompositeKeys/CompositeDbContext.cs | 31 + .../CompositeKeys/CompositeKeyTests.cs | 572 +++++++++ .../CompositeKeys/Dealership.cs | 15 + .../CompositeKeys/DealershipsController.cs | 16 + .../IntegrationTests/CompositeKeys/Engine.cs | 14 + .../CompositeKeys/EnginesController.cs | 16 + .../IntegrationTests/Filtering/FilterTests.cs | 2 +- .../IntegrationTests/Includes/IncludeTests.cs | 2 +- .../IntegrationTests/Logging/LoggingTests.cs | 69 ++ ...{ResourceTests.cs => ResourceMetaTests.cs} | 6 +- .../ModelStateValidationTests.cs | 280 +++-- .../NoModelStateValidationTests.cs | 24 +- .../ResourceDefinitionQueryCallbackTests.cs | 6 +- .../InheritanceDbContext.cs | 6 +- .../ResourceInheritance/InheritanceTests.cs | 368 ++++-- .../ResourceInheritance/Models/Human.cs | 11 +- .../SoftDeletion/SoftDeletionTests.cs | 47 +- .../IntegrationTests/Sorting/SortTests.cs | 2 +- .../ResultCapturingRepository.cs | 7 +- .../SparseFieldSets/SparseFieldSetTests.cs | 28 +- .../Writing/Creating/CreateResourceTests.cs | 707 +++++++++++ ...reateResourceWithClientGeneratedIdTests.cs | 244 ++++ ...eateResourceWithToManyRelationshipTests.cs | 663 ++++++++++ ...reateResourceWithToOneRelationshipTests.cs | 536 ++++++++ .../Writing/Deleting/DeleteResourceTests.cs | 222 ++++ .../IntegrationTests/Writing/RgbColor.cs | 16 + .../Writing/RgbColorsController.cs | 16 + .../AddToToManyRelationshipTests.cs | 664 ++++++++++ .../RemoveFromToManyRelationshipTests.cs | 661 ++++++++++ .../ReplaceToManyRelationshipTests.cs | 726 +++++++++++ .../UpdateToOneRelationshipTests.cs | 552 +++++++++ .../ReplaceToManyRelationshipTests.cs | 838 +++++++++++++ .../Updating/Resources/UpdateResourceTests.cs | 1079 +++++++++++++++++ .../Resources/UpdateToOneRelationshipTests.cs | 661 ++++++++++ .../IntegrationTests/Writing/UserAccount.cs | 18 + .../Writing/UserAccountsController.cs | 16 + .../IntegrationTests/Writing/WorkItem.cs | 38 + .../IntegrationTests/Writing/WorkItemGroup.cs | 27 + .../Writing/WorkItemGroupsController.cs | 17 + .../Writing/WorkItemPriority.cs | 9 + .../IntegrationTests/Writing/WorkItemTag.cs | 11 + .../Writing/WorkItemsController.cs | 16 + .../IntegrationTests/Writing/WorkTag.cs | 14 + .../Writing/WriteDbContext.cs | 38 + .../IntegrationTests/Writing/WriteFakers.cs | 111 ++ test/MultiDbContextTests/ResourceTests.cs | 2 +- test/NoEntityFrameworkTests/WorkItemTests.cs | 6 +- .../BaseJsonApiController_Tests.cs | 22 +- .../Controllers/CoreJsonApiControllerTests.cs | 4 +- .../IServiceCollectionExtensionsTests.cs | 17 +- test/UnitTests/Internal/TypeHelper_Tests.cs | 2 +- .../Models/ResourceConstructionTests.cs | 14 +- .../QueryStringParameters/FilterParseTests.cs | 1 + .../RelationshipDictionaryTests.cs | 14 +- .../Create/AfterCreateTests.cs | 10 +- .../Create/BeforeCreate_WithDbValues_Tests.cs | 95 +- .../Update/BeforeUpdate_WithDbValues_Tests.cs | 120 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 23 +- .../Client/ResponseDeserializerTests.cs | 6 +- .../Common/DocumentParserTests.cs | 71 +- .../Common/ResourceObjectBuilderTests.cs | 25 +- .../IncludedResourceObjectBuilderTests.cs | 2 +- .../Server/RequestDeserializerTests.cs | 16 +- .../Server/ResponseSerializerTests.cs | 89 -- .../Services/DefaultResourceService_Tests.cs | 11 +- 195 files changed, 12345 insertions(+), 4348 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/NonJsonApiResource.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs rename src/JsonApiDotNetCore/Configuration/{IInverseRelationships.cs => IInverseRelationshipResolver.cs} (79%) delete mode 100644 src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs rename src/JsonApiDotNetCore/Configuration/{InverseRelationships.cs => InverseRelationshipResolver.cs} (87%) delete mode 100644 src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs create mode 100644 src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs create mode 100644 src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs create mode 100644 src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs create mode 100644 src/JsonApiDotNetCore/Errors/SecondaryResourcesNotFoundException.cs create mode 100644 src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs create mode 100644 src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs delete mode 100644 src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs create mode 100644 src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs create mode 100644 src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs create mode 100644 src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs delete mode 100644 src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs create mode 100644 src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs delete mode 100644 src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs create mode 100644 src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs create mode 100644 src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs create mode 100644 src/JsonApiDotNetCore/Services/GetResourcesByIds.cs create mode 100644 src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs create mode 100644 src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs create mode 100644 src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs create mode 100644 src/JsonApiDotNetCore/Services/ISetRelationshipService.cs delete mode 100644 src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/{ResourceTests.cs => ResourceMetaTests.cs} (93%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs index 6a373eb73c..3ca960ef87 100644 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs @@ -3,6 +3,7 @@ using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; @@ -37,7 +38,8 @@ public JsonApiDeserializerBenchmarks() var options = new JsonApiOptions(); IResourceGraph resourceGraph = DependencyFactory.CreateResourceGraph(options); var targetedFields = new TargetedFields(); - _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new ResourceFactory(new ServiceContainer()), targetedFields, new HttpContextAccessor()); + var request = new JsonApiRequest(); + _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new ResourceFactory(new ServiceContainer()), targetedFields, new HttpContextAccessor(), request); } [Benchmark] diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index da0fa71ca1..f9f294c26b 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -49,7 +49,7 @@ private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resource var accessor = new Mock().Object; - return new FieldsToSerialize(resourceGraph, constraintProviders, accessor); + return new FieldsToSerialize(resourceGraph, constraintProviders, accessor, request); } [Benchmark] diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index fb75b37226..ab63c950d9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -1,11 +1,9 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; @@ -133,9 +131,10 @@ public async Task PatchAsync(TId id, [FromBody] T resource) } [HttpPatch("{id}/relationships/{relationshipName}")] - public async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) + public async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds) { - await _resourceService.UpdateRelationshipAsync(id, relationshipName, relationships); + await _resourceService.SetRelationshipAsync(id, relationshipName, secondaryResourceIds); + return Ok(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index 2480d40f04..e53ce44723 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -69,8 +69,8 @@ public override async Task PatchAsync(int id, [FromBody] TodoItem [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - int id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipAsync(id, relationshipName, relationships); + int id, string relationshipName, [FromBody] object secondaryResourceIds) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds); [HttpDelete("{id}")] public override async Task DeleteAsync(int id) diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 20ba2d0f88..c951e412e6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -39,24 +39,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(t => t.Assignee) - .WithMany(p => p.AssignedTodoItems) - .HasForeignKey(t => t.AssigneeId); + .WithMany(p => p.AssignedTodoItems); modelBuilder.Entity() .HasOne(t => t.Owner) - .WithMany(p => p.TodoItems) - .HasForeignKey(t => t.OwnerId); + .WithMany(p => p.TodoItems); modelBuilder.Entity() - .HasKey(bc => new { bc.ArticleId, bc.TagId }); + .HasKey(bc => new {bc.ArticleId, bc.TagId}); modelBuilder.Entity() - .HasKey(bc => new { bc.ArticleId, bc.TagId }); + .HasKey(bc => new {bc.ArticleId, bc.TagId}); modelBuilder.Entity() .HasOne(t => t.StakeHolderTodoItem) .WithMany(t => t.StakeHolders) - .HasForeignKey(t => t.StakeHolderTodoItemId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() @@ -64,13 +61,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasMany(t => t.ChildrenTodos) - .WithOne(t => t.ParentTodo) - .HasForeignKey(t => t.ParentTodoId); + .WithOne(t => t.ParentTodo); modelBuilder.Entity() .HasOne(p => p.Person) .WithOne(p => p.Passport) - .HasForeignKey(p => p.PassportId) + .HasForeignKey("PassportKey") .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() @@ -81,7 +77,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(p => p.OneToOnePerson) .WithOne(p => p.OneToOneTodoItem) - .HasForeignKey(p => p.OneToOnePersonId); + .HasForeignKey("OneToOnePersonKey"); modelBuilder.Entity() .HasOne(p => p.Owner) @@ -89,9 +85,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() - .HasOne(p => p.OneToOneTodoItem) - .WithOne(p => p.OneToOnePerson) - .HasForeignKey(p => p.OneToOnePersonId); + .HasOne(p => p.Role) + .WithOne(p => p.Person) + .HasForeignKey("PersonRoleKey"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs index 317ecf5e65..b0e4d59435 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs @@ -1,6 +1,3 @@ -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - namespace JsonApiDotNetCoreExample.Models { public sealed class ArticleTag @@ -11,17 +8,4 @@ public sealed class ArticleTag public int TagId { get; set; } public Tag Tag { get; set; } } - - public class IdentifiableArticleTag : Identifiable - { - public int ArticleId { get; set; } - [HasOne] - public Article Article { get; set; } - - public int TagId { get; set; } - [HasOne] - public Tag Tag { get; set; } - - public string SomeMetaData { get; set; } - } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs new file mode 100644 index 0000000000..3183540aba --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Models +{ + public class IdentifiableArticleTag : Identifiable + { + public int ArticleId { get; set; } + [HasOne] + public Article Article { get; set; } + + public int TagId { get; set; } + [HasOne] + public Tag Tag { get; set; } + + public string SomeMetaData { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/NonJsonApiResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/NonJsonApiResource.cs deleted file mode 100644 index 7f979f4cfb..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/NonJsonApiResource.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace JsonApiDotNetCoreExample.Models -{ - public class NonJsonApiResource - { - public int Id { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index 483ecceeda..c66d74874e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -51,7 +51,7 @@ public int? SocialSecurityNumber [NotMapped] public string BirthCountryName { - get => BirthCountry.Name; + get => BirthCountry?.Name; set { BirthCountry ??= new Country(); diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index db610ac0f2..b5d67fb5a0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -57,20 +57,17 @@ public string FirstName [HasOne] public PersonRole Role { get; set; } - public int? PersonRoleId { get; set; } [HasOne] public TodoItem OneToOneTodoItem { get; set; } [HasOne] public TodoItem StakeHolderTodoItem { get; set; } - public int? StakeHolderTodoItemId { get; set; } [HasOne(Links = LinkTypes.All, CanInclude = false)] public TodoItem UnIncludeableItem { get; set; } [HasOne] public Passport Passport { get; set; } - public int? PassportId { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index dbee65a188..ace6f23711 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -18,11 +18,4 @@ public class Tag : Identifiable public ISet
Articles { get; set; } public ISet ArticleTags { get; set; } } - - public enum TagColor - { - Red, - Green, - Blue - } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs new file mode 100644 index 0000000000..8ae4552afe --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExample.Models +{ + public enum TagColor + { + Red, + Green, + Blue + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 782b2521be..64afada036 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -7,11 +7,6 @@ namespace JsonApiDotNetCoreExample.Models { public class TodoItem : Identifiable, IIsLockable { - public TodoItem() - { - GuidProperty = Guid.NewGuid(); - } - public bool IsLocked { get; set; } [Attr] @@ -20,9 +15,6 @@ public TodoItem() [Attr] public long Ordinal { get; set; } - [Attr] - public Guid GuidProperty { get; set; } - [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)] public string AlwaysChangingValue { @@ -36,21 +28,12 @@ public string AlwaysChangingValue [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] public DateTime? AchievedDate { get; set; } - [Attr] - public DateTime? UpdatedDate { get; set; } - [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public string CalculatedValue => "calculated"; [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public DateTimeOffset? OffsetDate { get; set; } - public int? OwnerId { get; set; } - - public int? AssigneeId { get; set; } - - public Guid? CollectionId { get; set; } - [HasOne] public Person Owner { get; set; } @@ -60,8 +43,6 @@ public string AlwaysChangingValue [HasOne] public Person OneToOnePerson { get; set; } - public int? OneToOnePersonId { get; set; } - [HasMany] public ISet StakeHolders { get; set; } @@ -69,14 +50,10 @@ public string AlwaysChangingValue public TodoItemCollection Collection { get; set; } // cyclical to-one structure - public int? DependentOnTodoId { get; set; } - [HasOne] public TodoItem DependentOnTodo { get; set; } // cyclical to-many structure - public int? ParentTodoId {get; set;} - [HasOne] public TodoItem ParentTodo { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs index 9b0a515f25..4f3c88e152 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs @@ -16,7 +16,5 @@ public sealed class TodoItemCollection : Identifiable [HasOne] public Person Owner { get; set; } - - public int? OwnerId { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 430b65e27e..62025f5e98 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -15,6 +15,7 @@ public class CustomArticleService : JsonApiResourceService
{ public CustomArticleService( IResourceRepository
repository, + IGetResourcesByIds getResourcesById, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -22,9 +23,11 @@ public CustomArticleService( IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) - : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) + ITargetedFields targetedFields, + IResourceContextProvider resourceContextProvider, + IResourceHookExecutorFacade hookExecutor) + : base(repository, getResourcesById, queryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) { } public override async Task
GetAsync(int id) diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 85116981ba..628e566a58 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -35,7 +35,9 @@ public override void ConfigureServices(IServiceCollection services) { options.EnableSensitiveDataLogging(); options.UseNpgsql(_connectionString, innerOptions => innerOptions.SetPostgresVersion(new Version(9, 6))); - }, ServiceLifetime.Transient); + }, + // TODO: Remove ServiceLifetime.Transient, after all integration tests have been converted to use IntegrationTestContext. + ServiceLifetime.Transient); services.AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index 751c02703a..14cb6264da 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; using MultiDbContextExample.Data; @@ -12,11 +13,10 @@ public class DbContextARepository : EntityFrameworkCoreRepository { public DbContextARepository(ITargetedFields targetedFields, DbContextResolver contextResolver, - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, - constraintProviders, loggerFactory) + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IGetResourcesByIds getResourcesByIds, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, + constraintProviders, getResourcesByIds, loggerFactory) { } } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index c0761187b1..c6a59b6883 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; using MultiDbContextExample.Data; @@ -12,11 +13,10 @@ public class DbContextBRepository : EntityFrameworkCoreRepository { public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver contextResolver, - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, - constraintProviders, loggerFactory) + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IGetResourcesByIds getResourcesByIds, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, + constraintProviders, getResourcesByIds, loggerFactory) { } } diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 8eeae612c7..8e87d51763 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Dapper; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.Extensions.Configuration; using NoEntityFrameworkExample.Models; @@ -40,7 +41,7 @@ public Task GetSecondaryAsync(int id, string relationshipName) throw new NotImplementedException(); } - public Task GetRelationshipAsync(int id, string relationshipName) + public Task GetRelationshipAsync(int id, string relationshipName) { throw new NotImplementedException(); } @@ -61,12 +62,22 @@ await QueryAsync(async connection => await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new { id })); } - public Task UpdateAsync(int id, WorkItem requestResource) + public Task UpdateAsync(int id, WorkItem resource) { throw new NotImplementedException(); } - public Task UpdateRelationshipAsync(int id, string relationshipName, object relationships) + public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) + { + throw new NotImplementedException(); + } + + public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) + { + throw new NotImplementedException(); + } + + public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) { throw new NotImplementedException(); } diff --git a/src/Examples/ReportsExample/Controllers/ReportsController.cs b/src/Examples/ReportsExample/Controllers/ReportsController.cs index c80aba4680..74bb2301a5 100644 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ b/src/Examples/ReportsExample/Controllers/ReportsController.cs @@ -11,8 +11,8 @@ namespace ReportsExample.Controllers [Route("api/[controller]")] public class ReportsController : BaseJsonApiController { - public ReportsController( - IJsonApiOptions options, + public ReportsController( + IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll) : base(options, loggerFactory, getAll) diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 0d4d20f59c..99b671e411 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -25,7 +25,7 @@ public static void UseJsonApi(this IApplicationBuilder builder) if (builder == null) throw new ArgumentNullException(nameof(builder)); using var scope = builder.ApplicationServices.GetRequiredService().CreateScope(); - var inverseRelationshipResolver = scope.ServiceProvider.GetRequiredService(); + var inverseRelationshipResolver = scope.ServiceProvider.GetRequiredService(); inverseRelationshipResolver.Resolve(); var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); diff --git a/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs b/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs similarity index 79% rename from src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs rename to src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs index b15afea2ce..c9e4e10722 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs @@ -3,20 +3,19 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Responsible for populating the property. + /// Responsible for populating the property. /// /// This service is instantiated in the configure phase of the application. /// /// When using a data access layer different from EF Core, and when using ResourceHooks /// that depend on the inverse navigation property (BeforeImplicitUpdateRelationship), - /// you will need to override this service, or pass along the inverseNavigationProperty in + /// you will need to override this service, or pass along the InverseNavigationProperty in /// the RelationshipAttribute. /// - public interface IInverseRelationships + public interface IInverseRelationshipResolver { /// - /// This method is called upon startup by JsonApiDotNetCore. It should - /// deal with resolving the inverse relationships. + /// This method is called upon startup by JsonApiDotNetCore. It resolves inverse relationships. /// void Resolve(); } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index fded94294a..6fd6a62b39 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -32,7 +32,7 @@ public interface IJsonApiOptions bool IncludeExceptionStackTraceInErrors { get; } /// - /// Use relative links for all resources. + /// Use relative links for all resources. False by default. /// /// /// diff --git a/src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs b/src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs deleted file mode 100644 index 71c813d608..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Provides an interface for formatting relationship identifiers from the navigation property name. - /// - public interface IRelatedIdMapper - { - /// - /// Gets the internal property name for the database mapped identifier property. - /// - /// - /// - /// RelatedIdMapper.GetRelatedIdPropertyName("Article"); // returns "ArticleId" - /// - /// - string GetRelatedIdPropertyName(string propertyName); - } -} diff --git a/src/JsonApiDotNetCore/Configuration/InverseRelationships.cs b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs similarity index 87% rename from src/JsonApiDotNetCore/Configuration/InverseRelationships.cs rename to src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs index f8164b490e..12491d38cd 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseRelationships.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCore.Configuration { /// - public class InverseRelationships : IInverseRelationships + public class InverseRelationshipResolver : IInverseRelationshipResolver { private readonly IResourceContextProvider _resourceContextProvider; private readonly IEnumerable _dbContextResolvers; - public InverseRelationships(IResourceContextProvider resourceContextProvider, + public InverseRelationshipResolver(IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers) { _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); @@ -42,7 +42,7 @@ private void Resolve(DbContext dbContext) if (!(relationship is HasManyThroughAttribute)) { INavigation inverseNavigation = entityType.FindNavigation(relationship.Property.Name)?.FindInverse(); - relationship.InverseNavigation = inverseNavigation?.Name; + relationship.InverseNavigationProperty = inverseNavigation?.PropertyInfo; } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index eb742cacd4..f33d6dcda9 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -139,17 +139,13 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) AddSerializationLayer(); AddQueryStringLayer(); - if (_options.EnableResourceHooks) - { - AddResourceHooks(); - } + AddResourceHooks(); _services.AddScoped(); - _services.AddScoped(typeof(RepositoryRelationshipUpdateHelper<>)); _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.AddScoped(); _services.AddScoped(); - _services.TryAddScoped(); + _services.TryAddScoped(); } private void AddMiddlewareLayer() @@ -175,55 +171,40 @@ private void AddMiddlewareLayer() private void AddResourceLayer() { - _services.AddScoped(typeof(IResourceDefinition<>), typeof(JsonApiResourceDefinition<>)); - _services.AddScoped(typeof(IResourceDefinition<,>), typeof(JsonApiResourceDefinition<,>)); - _services.AddScoped(); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ResourceDefinitionInterfaces, + typeof(JsonApiResourceDefinition<>), typeof(JsonApiResourceDefinition<,>)); + _services.AddScoped(); _services.AddScoped(); - _services.AddSingleton(sp => sp.GetRequiredService()); } private void AddRepositoryLayer() { - _services.AddScoped(typeof(IResourceRepository<>), typeof(EntityFrameworkCoreRepository<>)); - _services.AddScoped(typeof(IResourceRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, + typeof(EntityFrameworkCoreRepository<>), typeof(EntityFrameworkCoreRepository<,>)); - _services.AddScoped(typeof(IResourceReadRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); - _services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); + _services.AddScoped(); } private void AddServiceLayer() { - _services.AddScoped(typeof(ICreateService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(ICreateService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetAllService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetAllService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetByIdService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetByIdService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetRelationshipService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetRelationshipService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IGetSecondaryService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IGetSecondaryService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IUpdateService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IUpdateService<,>), typeof(JsonApiResourceService<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, + typeof(JsonApiResourceService<>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IDeleteService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IDeleteService<,>), typeof(JsonApiResourceService<,>)); + _services.AddScoped(); + } - _services.AddScoped(typeof(IResourceService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IResourceService<,>), typeof(JsonApiResourceService<,>)); + private void RegisterImplementationForOpenInterfaces(HashSet openGenericInterfaces, Type intImplementation, Type implementation) + { + foreach (var openGenericInterface in openGenericInterfaces) + { + var implementationType = openGenericInterface.GetGenericArguments().Length == 1 + ? intImplementation + : implementation; - _services.AddScoped(typeof(IResourceQueryService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IResourceQueryService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(typeof(IResourceCommandService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IResourceCommandService<,>), typeof(JsonApiResourceService<,>)); + _services.AddScoped(openGenericInterface, implementationType); + } } private void AddQueryStringLayer() @@ -258,12 +239,20 @@ private void AddQueryStringLayer() } private void AddResourceHooks() - { - _services.AddSingleton(typeof(IHooksDiscovery<>), typeof(HooksDiscovery<>)); - _services.AddScoped(typeof(IResourceHookContainer<>), typeof(ResourceHooksDefinition<>)); - _services.AddTransient(typeof(IResourceHookExecutor), typeof(ResourceHookExecutor)); - _services.AddTransient(); - _services.AddTransient(); + { + if (_options.EnableResourceHooks) + { + _services.AddSingleton(typeof(IHooksDiscovery<>), typeof(HooksDiscovery<>)); + _services.AddScoped(typeof(IResourceHookContainer<>), typeof(ResourceHooksDefinition<>)); + _services.AddTransient(); + _services.AddTransient(); + _services.AddScoped(); + _services.AddScoped(); + } + else + { + _services.AddSingleton(); + } } private void AddSerializationLayer() diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index b62e86f0af..c999508574 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -76,11 +76,6 @@ public sealed class JsonApiOptions : IJsonApiOptions } }; - /// - /// Provides an interface for formatting relationship ID properties given the navigation property name. - /// - public static IRelatedIdMapper RelatedIdMapper { get; set; } = new RelatedIdMapper(); - // Workaround for https://github.com/dotnet/efcore/issues/21026 internal bool DisableTopPagination { get; set; } internal bool DisableChildrenPagination { get; set; } diff --git a/src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs b/src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs deleted file mode 100644 index 0d238aeb5b..0000000000 --- a/src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace JsonApiDotNetCore.Configuration -{ - /// - public sealed class RelatedIdMapper : IRelatedIdMapper - { - /// - public string GetRelatedIdPropertyName(string propertyName) => propertyName + "Id"; - } -} diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index a1acc938d8..deb64895dd 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -90,10 +90,14 @@ public RelationshipAttribute GetInverseRelationship(RelationshipAttribute relati { if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - if (relationship.InverseNavigation == null) return null; + if (relationship.InverseNavigationProperty == null) + { + return null; + } + return GetResourceContext(relationship.RightType) - .Relationships - .SingleOrDefault(r => r.Property.Name == relationship.InverseNavigation); + .Relationships + .SingleOrDefault(r => r.Property == relationship.InverseNavigationProperty); } private IReadOnlyCollection Getter(Expression> selector = null, FieldFilterType type = FieldFilterType.None) where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index bdbc86cda5..be0eb85682 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -107,7 +107,7 @@ public ResourceGraphBuilder Add(Type resourceType, Type idType = null, string pu IdentityType = idType, Attributes = GetAttributes(resourceType), Relationships = GetRelationships(resourceType), - EagerLoads = GetEagerLoads(resourceType), + EagerLoads = GetEagerLoads(resourceType) }; private IReadOnlyCollection GetAttributes(Type resourceType) @@ -191,7 +191,7 @@ private IReadOnlyCollection GetRelationships(Type resourc ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {resourceType}"); // ArticleTag.ArticleId - var leftIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.LeftProperty.Name); + var leftIdPropertyName = hasManyThroughAttribute.LeftIdPropertyName ?? hasManyThroughAttribute.LeftProperty.Name + "Id"; hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(x => x.Name == leftIdPropertyName) ?? throw new InvalidConfigurationException($"{throughType} does not contain a relationship ID property to type {resourceType} with name {leftIdPropertyName}"); @@ -200,7 +200,7 @@ private IReadOnlyCollection GetRelationships(Type resourc ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}"); // ArticleTag.TagId - var rightIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.RightProperty.Name); + var rightIdPropertyName = hasManyThroughAttribute.RightIdPropertyName ?? hasManyThroughAttribute.RightProperty.Name + "Id"; hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(x => x.Name == rightIdPropertyName) ?? throw new InvalidConfigurationException($"{throughType} does not contain a relationship ID property to type {hasManyThroughAttribute.RightType} with name {rightIdPropertyName}"); } diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 61f159ae1c..b9a4926d74 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -24,8 +24,6 @@ public class ServiceDiscoveryFacade typeof(IResourceCommandService<,>), typeof(IResourceQueryService<>), typeof(IResourceQueryService<,>), - typeof(ICreateService<>), - typeof(ICreateService<,>), typeof(IGetAllService<>), typeof(IGetAllService<,>), typeof(IGetByIdService<>), @@ -34,13 +32,21 @@ public class ServiceDiscoveryFacade typeof(IGetSecondaryService<,>), typeof(IGetRelationshipService<>), typeof(IGetRelationshipService<,>), + typeof(ICreateService<>), + typeof(ICreateService<,>), + typeof(IAddToRelationshipService<>), + typeof(IAddToRelationshipService<,>), typeof(IUpdateService<>), typeof(IUpdateService<,>), + typeof(ISetRelationshipService<>), + typeof(ISetRelationshipService<,>), typeof(IDeleteService<>), - typeof(IDeleteService<,>) + typeof(IDeleteService<,>), + typeof(IRemoveFromRelationshipService<>), + typeof(IRemoveFromRelationshipService<,>) }; - private static readonly HashSet _repositoryInterfaces = new HashSet { + internal static readonly HashSet RepositoryInterfaces = new HashSet { typeof(IResourceRepository<>), typeof(IResourceRepository<,>), typeof(IResourceWriteRepository<>), @@ -49,7 +55,7 @@ public class ServiceDiscoveryFacade typeof(IResourceReadRepository<,>) }; - private static readonly HashSet _resourceDefinitionInterfaces = new HashSet { + internal static readonly HashSet ResourceDefinitionInterfaces = new HashSet { typeof(IResourceDefinition<>), typeof(IResourceDefinition<,>) }; @@ -168,7 +174,7 @@ private void AddServices(Assembly assembly, ResourceDescriptor resourceDescripto private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) { - foreach (var repositoryInterface in _repositoryInterfaces) + foreach (var repositoryInterface in RepositoryInterfaces) { RegisterImplementations(assembly, repositoryInterface, resourceDescriptor); } @@ -176,7 +182,7 @@ private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescr private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resourceDescriptor) { - foreach (var resourceDefinitionInterface in _resourceDefinitionInterfaces) + foreach (var resourceDefinitionInterface in ResourceDefinitionInterfaces) { RegisterImplementations(assembly, resourceDefinitionInterface, resourceDescriptor); } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index d33ad1aecc..616dce670e 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -24,9 +25,11 @@ public abstract class BaseJsonApiController : CoreJsonApiControl private readonly IGetSecondaryService _getSecondary; private readonly IGetRelationshipService _getRelationship; private readonly ICreateService _create; + private readonly IAddToRelationshipService _addToRelationship; private readonly IUpdateService _update; - private readonly IUpdateRelationshipService _updateRelationships; + private readonly ISetRelationshipService _setRelationship; private readonly IDeleteService _delete; + private readonly IRemoveFromRelationshipService _removeFromRelationship; private readonly TraceLogWriter> _traceWriter; /// @@ -36,8 +39,7 @@ protected BaseJsonApiController( IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : this(options, loggerFactory, resourceService, resourceService, resourceService, resourceService, - resourceService, resourceService, resourceService, resourceService) + : this(options, loggerFactory, resourceService, resourceService) { } /// @@ -49,7 +51,7 @@ protected BaseJsonApiController( IResourceQueryService queryService = null, IResourceCommandService commandService = null) : this(options, loggerFactory, queryService, queryService, queryService, queryService, commandService, - commandService, commandService, commandService) + commandService, commandService, commandService, commandService, commandService) { } /// @@ -63,9 +65,11 @@ protected BaseJsonApiController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, - IDeleteService delete = null) + ISetRelationshipService setRelationship = null, + IDeleteService delete = null, + IRemoveFromRelationshipService removeFromRelationship = null) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -76,9 +80,11 @@ protected BaseJsonApiController( _getSecondary = getSecondary; _getRelationship = getRelationship; _create = create; + _addToRelationship = addToRelationship; _update = update; - _updateRelationships = updateRelationships; + _setRelationship = setRelationship; _delete = delete; + _removeFromRelationship = removeFromRelationship; } /// @@ -91,6 +97,7 @@ public virtual async Task GetAsync() if (_getAll == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var resources = await _getAll.GetAsync(); + return Ok(resources); } @@ -104,53 +111,55 @@ public virtual async Task GetAsync(TId id) if (_getById == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var resource = await _getById.GetAsync(id); + return Ok(resource); } /// - /// Gets a single resource relationship. - /// Example: GET /articles/1/relationships/author HTTP/1.1 + /// Gets a single resource or multiple resources at a nested endpoint. + /// Examples: + /// GET /articles/1/author HTTP/1.1 + /// GET /articles/1/revisions HTTP/1.1 /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); + if (_getSecondary == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); return Ok(relationship); } /// - /// Gets a single resource or multiple resources at a nested endpoint. - /// Examples: - /// GET /articles/1/author HTTP/1.1 - /// GET /articles/1/revisions HTTP/1.1 + /// Gets a single resource relationship. + /// Example: GET /articles/1/relationships/author HTTP/1.1 + /// Example: GET /articles/1/relationships/revisions HTTP/1.1 /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName) + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_getSecondary == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); - return Ok(relationship); + if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var rightResources = await _getRelationship.GetRelationshipAsync(id, relationshipName); + + return Ok(rightResources); } /// - /// Creates a new resource. + /// Creates a new resource with attributes, relationships or both. + /// Example: POST /articles HTTP/1.1 /// public virtual async Task PostAsync([FromBody] TResource resource) { _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); if (_create == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - if (resource == null) - throw new InvalidRequestBodyException(null, null, null); - if (!_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) throw new ResourceIdInPostRequestNotAllowedException(); @@ -162,19 +171,41 @@ public virtual async Task PostAsync([FromBody] TResource resource resource = await _create.CreateAsync(resource); - return Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); + return resource == null + ? (IActionResult) NoContent() + : Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); + } + + /// + /// Adds resources to a to-many relationship. + /// Example: POST /articles/1/revisions HTTP/1.1 + /// + /// The identifier of the primary resource. + /// The relationship to add resources to. + /// The set of resources to add to the relationship. + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) + { + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); + + if (_addToRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); + await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); + + return NoContent(); } /// - /// Updates an existing resource. May contain a partial set of attributes. + /// Updates 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. + /// Example: PATCH /articles/1 HTTP/1.1 /// public virtual async Task PatchAsync(TId id, [FromBody] TResource resource) { _traceWriter.LogMethodStart(new {id, resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - if (resource == null) - throw new InvalidRequestBodyException(null, null, null); if (_options.ValidateModelState && !ModelState.IsValid) { @@ -183,24 +214,31 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource } var updated = await _update.UpdateAsync(id, resource); - return updated == null ? Ok(null) : Ok(updated); + return updated == null ? (IActionResult) NoContent() : Ok(updated); } /// - /// Updates a relationship. + /// Performs a complete replacement of a relationship on an existing resource. + /// Example: PATCH /articles/1/relationships/author HTTP/1.1 + /// Example: PATCH /articles/1/relationships/revisions HTTP/1.1 /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) + /// The identifier of the primary resource. + /// The relationship for which to perform a complete replacement. + /// The resource or set of resources to assign to the relationship. + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_updateRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _updateRelationships.UpdateRelationshipAsync(id, relationshipName, relationships); - return Ok(); + if (_setRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); + await _setRelationship.SetRelationshipAsync(id, relationshipName, secondaryResourceIds); + + return NoContent(); } /// - /// Deletes a resource. + /// Deletes an existing resource. + /// Example: DELETE /articles/1 HTTP/1.1 /// public virtual async Task DeleteAsync(TId id) { @@ -211,6 +249,25 @@ public virtual async Task DeleteAsync(TId id) return NoContent(); } + + /// + /// Removes resources from a to-many relationship. + /// Example: DELETE /articles/1/relationships/revisions HTTP/1.1 + /// + /// The identifier of the primary resource. + /// The relationship to remove resources from. + /// The set of resources to remove from the relationship. + public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) + { + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); + + if (_removeFromRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); + await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds); + + return NoContent(); + } } /// @@ -242,11 +299,13 @@ protected BaseJsonApiController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, - IDeleteService delete = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationships, delete) + ISetRelationshipService setRelationship = null, + IDeleteService delete = null, + IRemoveFromRelationshipService removeFromRelationship = null) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, + setRelationship, delete, removeFromRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 6e4b85dc4d..ea00374638 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -29,6 +30,12 @@ protected JsonApiCommandController( public override async Task PostAsync([FromBody] TResource resource) => await base.PostAsync(resource); + /// + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) + => await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds); + /// [HttpPatch("{id}")] public override async Task PatchAsync(TId id, [FromBody] TResource resource) @@ -37,12 +44,17 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipAsync(id, relationshipName, relationships); + TId id, string relationshipName, [FromBody] object secondaryResourceIds) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds); /// [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); + + /// + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) + => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds); } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 1fd42b97aa..dcdbaee2aa 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -33,11 +34,13 @@ public JsonApiController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, - IDeleteService delete = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationships, delete) + ISetRelationshipService setRelationship = null, + IDeleteService delete = null, + IRemoveFromRelationshipService removeFromRelationship = null) + : base(options, loggerFactory,getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, + setRelationship, delete, removeFromRelationship) { } /// @@ -47,22 +50,28 @@ public JsonApiController( /// [HttpGet("{id}")] public override async Task GetAsync(TId id) => await base.GetAsync(id); - - /// - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName) - => await base.GetRelationshipAsync(id, relationshipName); - + /// [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); + + /// + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(TId id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); /// [HttpPost] public override async Task PostAsync([FromBody] TResource resource) => await base.PostAsync(resource); + /// + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) + => await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds); + /// [HttpPatch("{id}")] public override async Task PatchAsync(TId id, [FromBody] TResource resource) @@ -73,12 +82,17 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipAsync( - TId id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipAsync(id, relationshipName, relationships); + TId id, string relationshipName, [FromBody] object secondaryResourceIds) + => await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds); /// [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); + + /// + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) + => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds); } /// @@ -101,11 +115,13 @@ public JsonApiController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, - IDeleteService delete = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, - updateRelationships, delete) + ISetRelationshipService setRelationship = null, + IDeleteService delete = null, + IRemoveFromRelationshipService removeFromRelationship = null) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, + setRelationship, delete, removeFromRelationship) { } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index 89af9d95c8..4ca4e8d361 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -32,15 +32,15 @@ protected JsonApiQueryController( [HttpGet("{id}")] public override async Task GetAsync(TId id) => await base.GetAsync(id); - /// - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName) - => await base.GetRelationshipAsync(id, relationshipName); - /// [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); + + /// + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(TId id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); } /// diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index 1c91f34e9e..e49a453e07 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -10,14 +10,14 @@ namespace JsonApiDotNetCore.Errors public sealed class InvalidRequestBodyException : JsonApiException { private readonly string _details; - private string _requestBody; + private readonly string _requestBody; public InvalidRequestBodyException(string reason, string details, string requestBody, Exception innerException = null) : base(new Error(HttpStatusCode.UnprocessableEntity) { Title = reason != null ? "Failed to deserialize request body: " + reason - : "Failed to deserialize request body.", + : "Failed to deserialize request body." }, innerException) { _details = details; @@ -42,11 +42,5 @@ private void UpdateErrorDetail() Error.Detail = text; } - - public void SetRequestBody(string requestBody) - { - _requestBody = requestBody; - UpdateErrorDetail(); - } } } diff --git a/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs new file mode 100644 index 0000000000..421592abd8 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs @@ -0,0 +1,18 @@ +using System; + +namespace JsonApiDotNetCore.Errors +{ + public sealed class MissingResourceInRelationship + { + public string RelationshipName { get; } + public string ResourceType { get; } + public string ResourceId { get; } + + public MissingResourceInRelationship(string relationshipName, string resourceType, string resourceId) + { + RelationshipName = relationshipName ?? throw new ArgumentNullException(nameof(relationshipName)); + ResourceType = resourceType ?? throw new ArgumentNullException(nameof(resourceType)); + ResourceId = resourceId ?? throw new ArgumentNullException(nameof(resourceId)); + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs index a56091151a..313222f4a2 100644 --- a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs @@ -8,10 +8,10 @@ namespace JsonApiDotNetCore.Errors /// public sealed class RelationshipNotFoundException : JsonApiException { - public RelationshipNotFoundException(string relationshipName, string containingResourceName) : base(new Error(HttpStatusCode.NotFound) + public RelationshipNotFoundException(string relationshipName, string resourceType) : base(new Error(HttpStatusCode.NotFound) { Title = "The requested relationship does not exist.", - Detail = $"The resource '{containingResourceName}' does not contain a relationship named '{relationshipName}'." + Detail = $"Resource of type '{resourceType}' does not contain a relationship named '{relationshipName}'." }) { } diff --git a/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs new file mode 100644 index 0000000000..dcc0a0a66b --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs @@ -0,0 +1,20 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when creating a resource with an ID that already exists. + /// + public sealed class ResourceAlreadyExistsException : JsonApiException + { + public ResourceAlreadyExistsException(string resourceId, string resourceType) + : base(new Error(HttpStatusCode.Conflict) + { + Title = "Another resource with the specified ID already exists.", + Detail = $"Another resource of type '{resourceType}' with ID '{resourceId}' already exists." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs new file mode 100644 index 0000000000..17de1485bb --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs @@ -0,0 +1,19 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when trying to change the ID of an existing resource. + /// + public sealed class ResourceIdIsReadOnlyException : JsonApiException + { + public ResourceIdIsReadOnlyException() + : base(new Error(HttpStatusCode.Forbidden) + { + Title = "Resource ID is read-only.", + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index 22f6a57eaa..a9d127ee59 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -8,11 +8,12 @@ namespace JsonApiDotNetCore.Errors /// public sealed class ResourceNotFoundException : JsonApiException { - public ResourceNotFoundException(string resourceId, string resourceType) : base(new Error(HttpStatusCode.NotFound) - { - Title = "The requested resource does not exist.", - Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." - }) + public ResourceNotFoundException(string resourceId, string resourceType) + : base(new Error(HttpStatusCode.NotFound) + { + Title = "The requested resource does not exist.", + Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." + }) { } } diff --git a/src/JsonApiDotNetCore/Errors/SecondaryResourcesNotFoundException.cs b/src/JsonApiDotNetCore/Errors/SecondaryResourcesNotFoundException.cs new file mode 100644 index 0000000000..d43c063902 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/SecondaryResourcesNotFoundException.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when referencing one or more non-existing resources in one or more relationships. + /// + public sealed class SecondaryResourcesNotFoundException : Exception + { + public IReadOnlyCollection Errors { get; } + + public SecondaryResourcesNotFoundException(IEnumerable missingResources) + { + Errors = missingResources.Select(CreateError).ToList(); + } + + private Error CreateError(MissingResourceInRelationship missingResourceInRelationship) + { + return new Error(HttpStatusCode.NotFound) + { + Title = "A related resource does not exist.", + Detail = + $"Related resource of type '{missingResourceInRelationship.ResourceType}' with ID '{missingResourceInRelationship.ResourceId}' " + + $"in relationship '{missingResourceInRelationship.RelationshipName}' does not exist." + }; + } + } +} diff --git a/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs b/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs new file mode 100644 index 0000000000..5682ef9679 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs @@ -0,0 +1,20 @@ +using System.Net; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when an attempt is made to update a to-one relationship from a to-many relationship endpoint. + /// + public sealed class ToManyRelationshipRequiredException : JsonApiException + { + public ToManyRelationshipRequiredException(string relationshipName) + : base(new Error(HttpStatusCode.Forbidden) + { + Title = "Only to-many relationships can be updated through this endpoint.", + Detail = $"Relationship '{relationshipName}' must be a to-many relationship." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs index fc6457e019..f3c00d5c47 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs @@ -83,7 +83,7 @@ public IEnumerable LoadDbValues(LeftType resourceTypeForRepository, IEnumerable .GetMethod(nameof(GetWhereAndInclude), BindingFlags.NonPublic | BindingFlags.Instance) .MakeGenericMethod(resourceTypeForRepository, idType); var cast = ((IEnumerable)resources).Cast(); - var ids = TypeHelper.CopyToList(cast.Select(TypeHelper.GetResourceTypedId), idType); + var ids = TypeHelper.CopyToList(cast.Select(i => i.GetTypedId()), idType); var values = (IEnumerable)parameterizedGetWhere.Invoke(this, new object[] { ids, relationshipsToNextLayer }); if (values == null) return null; return (IEnumerable)Activator.CreateInstance(typeof(HashSet<>).MakeGenericType(resourceTypeForRepository), TypeHelper.CopyToList(values, resourceTypeForRepository)); @@ -130,15 +130,19 @@ private IHooksDiscovery GetHookDiscovery(Type resourceType) return discovery; } - private IEnumerable GetWhereAndInclude(IEnumerable ids, RelationshipAttribute[] relationshipsToNextLayer) where TResource : class, IIdentifiable + private IEnumerable GetWhereAndInclude(IReadOnlyCollection ids, RelationshipAttribute[] relationshipsToNextLayer) where TResource : class, IIdentifiable { + if (!ids.Any()) + { + return Array.Empty(); + } + var resourceContext = _resourceContextProvider.GetResourceContext(); - var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var filterExpression = CreateFilterByIds(ids, resourceContext); var queryLayer = new QueryLayer(resourceContext) { - Filter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), - ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()) + Filter = filterExpression }; var chains = relationshipsToNextLayer.Select(relationship => new ResourceFieldChainExpression(relationship)).ToList(); @@ -151,6 +155,21 @@ private IEnumerable GetWhereAndInclude(IEnumerable(IReadOnlyCollection ids, ResourceContext resourceContext) + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var idChain = new ResourceFieldChainExpression(idAttribute); + + if (ids.Count == 1) + { + var constant = new LiteralConstantExpression(ids.Single().ToString()); + return new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + } + + var constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList(); + return new EqualsAnyOfExpression(idChain, constants); + } + private IResourceReadRepository GetRepository() where TResource : class, IIdentifiable { return _genericProcessorFactory.Get>(typeof(IResourceReadRepository<,>), typeof(TResource), typeof(TId)); diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs index f60978586f..310bdb808c 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Hooks.Internal.Execution +namespace JsonApiDotNetCore.Hooks.Internal.Execution { /// @@ -18,7 +18,7 @@ public enum ResourceHook AfterRead, AfterUpdate, AfterDelete, - AfterUpdateRelationship, + AfterUpdateRelationship } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs index 6c27e2569b..56a5cc975f 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Hooks.Internal { /// /// Transient service responsible for executing Resource Hooks as defined - /// in . see methods in + /// in . See methods in /// , and /// for more information. /// diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs new file mode 100644 index 0000000000..e553b7948c --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Facade for execution of resource hooks. + /// + public interface IResourceHookExecutorFacade + { + void BeforeReadSingle(TId resourceId, ResourcePipeline pipeline) + where TResource : class, IIdentifiable; + + void AfterReadSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable; + + void BeforeReadMany() + where TResource : class, IIdentifiable; + + void AfterReadMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable; + + void BeforeCreate(TResource resource) + where TResource : class, IIdentifiable; + + void AfterCreate(TResource resource) + where TResource : class, IIdentifiable; + + void BeforeUpdateResource(TResource resource) + where TResource : class, IIdentifiable; + + void AfterUpdateResource(TResource resource) + where TResource : class, IIdentifiable; + + Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable; + + Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable; + + Task BeforeDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable; + + Task AfterDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable; + + void OnReturnSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable; + + IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable; + + object OnReturnRelationship(object resourceOrResources); + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs new file mode 100644 index 0000000000..7f3cc16f94 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Facade for hooks that does nothing, which is used when is false. + /// + public sealed class NeverResourceHookExecutorFacade : IResourceHookExecutorFacade + { + public void BeforeReadSingle(TId resourceId, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public void AfterReadSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public void BeforeReadMany() + where TResource : class, IIdentifiable + { + } + + public void AfterReadMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable + { + } + + public void BeforeCreate(TResource resource) + where TResource : class, IIdentifiable + { + } + + public void AfterCreate(TResource resource) + where TResource : class, IIdentifiable + { + } + + public void BeforeUpdateResource(TResource resource) + where TResource : class, IIdentifiable + { + } + + public void AfterUpdateResource(TResource resource) + where TResource : class, IIdentifiable + { + } + + public Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task BeforeDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task AfterDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public void OnReturnSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + } + + public IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable + { + return resources; + } + + public object OnReturnRelationship(object resourceOrResources) + { + return resourceOrResources; + } + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs index 1c0721a256..405634c8fd 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs @@ -23,22 +23,19 @@ internal sealed class ResourceHookExecutor : IResourceHookExecutor private readonly IEnumerable _constraintProviders; private readonly ITargetedFields _targetedFields; private readonly IResourceGraph _resourceGraph; - private readonly IResourceFactory _resourceFactory; public ResourceHookExecutor( IHookExecutorHelper executorHelper, ITraversalHelper traversalHelper, ITargetedFields targetedFields, IEnumerable constraintProviders, - IResourceGraph resourceGraph, - IResourceFactory resourceFactory) + IResourceGraph resourceGraph) { _executorHelper = executorHelper; _traversalHelper = traversalHelper; _targetedFields = targetedFields; _constraintProviders = constraintProviders; _resourceGraph = resourceGraph; - _resourceFactory = resourceFactory; } /// @@ -70,7 +67,7 @@ public IEnumerable BeforeUpdate(IEnumerable res var diff = new DiffableResourceHashSet(node.UniqueResources, dbValues, node.LeftsToNextLayer(), _targetedFields); IEnumerable updated = container.BeforeUpdate(diff, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, resources); + node.Reassign(resources); } FireNestedBeforeUpdateHooks(pipeline, _traversalHelper.CreateNextLayer(node)); @@ -85,7 +82,7 @@ public IEnumerable BeforeCreate(IEnumerable res var affected = new ResourceHashSet((HashSet)node.UniqueResources, node.LeftsToNextLayer()); IEnumerable updated = container.BeforeCreate(affected, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, resources); + node.Reassign(resources); } FireNestedBeforeUpdateHooks(pipeline, _traversalHelper.CreateNextLayer(node)); return resources; @@ -102,7 +99,7 @@ public IEnumerable BeforeDelete(IEnumerable res IEnumerable updated = container.BeforeDelete(affected, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, resources); + node.Reassign(resources); } // If we're deleting an article, we're implicitly affected any owners related to it. @@ -126,14 +123,14 @@ public IEnumerable OnReturn(IEnumerable resourc IEnumerable updated = container.OnReturn((HashSet)node.UniqueResources, pipeline); ValidateHookResponse(updated); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, resources); + node.Reassign(resources); } Traverse(_traversalHelper.CreateNextLayer(node), ResourceHook.OnReturn, (nextContainer, nextNode) => { var filteredUniqueSet = CallHook(nextContainer, ResourceHook.OnReturn, new object[] { nextNode.UniqueResources, pipeline }); nextNode.UpdateUnique(filteredUniqueSet); - nextNode.Reassign(_resourceFactory); + nextNode.Reassign(); }); return resources; } @@ -283,7 +280,7 @@ private void FireNestedBeforeUpdateHooks(ResourcePipeline pipeline, NodeLayer la var allowedIds = CallHook(nestedHookContainer, ResourceHook.BeforeUpdateRelationship, new object[] { GetIds(uniqueResources), resourcesByRelationship, pipeline }).Cast(); var updated = GetAllowedResources(uniqueResources, allowedIds); node.UpdateUnique(updated); - node.Reassign(_resourceFactory); + node.Reassign(); } } @@ -337,7 +334,7 @@ private Dictionary ReplaceKeysWithInverseRel // that the inverse attribute was also set (Owner has one Article: HasOneAttr:article). // If it isn't, JADNC currently knows nothing about this relationship pointing back, and it // currently cannot fire hooks for resources resolved through inverse relationships. - var inversableRelationshipAttributes = resourcesByRelationship.Where(kvp => kvp.Key.InverseNavigation != null); + var inversableRelationshipAttributes = resourcesByRelationship.Where(kvp => kvp.Key.InverseNavigationProperty != null); return inversableRelationshipAttributes.ToDictionary(kvp => _resourceGraph.GetInverseRelationship(kvp.Key), kvp => kvp.Value); } @@ -353,7 +350,7 @@ private void FireForAffectedImplicits(Type resourceTypeToInclude, Dictionary _resourceGraph.GetInverseRelationship(kvp.Key), kvp => kvp.Value); var resourcesByRelationship = CreateRelationshipHelper(resourceTypeToInclude, inverse); - CallHook(container, ResourceHook.BeforeImplicitUpdateRelationship, new object[] { resourcesByRelationship, pipeline, }); + CallHook(container, ResourceHook.BeforeImplicitUpdateRelationship, new object[] { resourcesByRelationship, pipeline}); } /// diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs new file mode 100644 index 0000000000..faf6cc2995 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Facade for hooks that invokes callbacks on , + /// which is used when is true. + /// + internal sealed class ResourceHookExecutorFacade : IResourceHookExecutorFacade + { + private readonly IResourceHookExecutor _resourceHookExecutor; + private readonly IResourceFactory _resourceFactory; + + public ResourceHookExecutorFacade(IResourceHookExecutor resourceHookExecutor, IResourceFactory resourceFactory) + { + _resourceHookExecutor = + resourceHookExecutor ?? throw new ArgumentNullException(nameof(resourceHookExecutor)); + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + } + + public void BeforeReadSingle(TId resourceId, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + var temporaryResource = _resourceFactory.CreateInstance(); + temporaryResource.Id = resourceId; + + _resourceHookExecutor.BeforeRead(pipeline, temporaryResource.StringId); + } + + public void AfterReadSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.AfterRead(ToList(resource), pipeline); + } + + public void BeforeReadMany() + where TResource : class, IIdentifiable + { + _resourceHookExecutor.BeforeRead(ResourcePipeline.Get); + } + + public void AfterReadMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.AfterRead(resources, ResourcePipeline.Get); + } + + public void BeforeCreate(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.BeforeCreate(ToList(resource), ResourcePipeline.Post); + } + + public void AfterCreate(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.AfterCreate(ToList(resource), ResourcePipeline.Post); + } + + public void BeforeUpdateResource(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.BeforeUpdate(ToList(resource), ResourcePipeline.Patch); + } + + public void AfterUpdateResource(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.AfterUpdate(ToList(resource), ResourcePipeline.Patch); + } + + public async Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + var resource = await getResourceAsync(); + _resourceHookExecutor.BeforeUpdate(ToList(resource), ResourcePipeline.PatchRelationship); + } + + public async Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + var resource = await getResourceAsync(); + _resourceHookExecutor.AfterUpdate(ToList(resource), ResourcePipeline.PatchRelationship); + } + + public async Task BeforeDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + var resource = await getResourceAsync(); + _resourceHookExecutor.BeforeDelete(ToList(resource), ResourcePipeline.Delete); + } + + public async Task AfterDeleteAsync(TId id, Func> getResourceAsync) + where TResource : class, IIdentifiable + { + var resource = await getResourceAsync(); + _resourceHookExecutor.AfterDelete(ToList(resource), ResourcePipeline.Delete, true); + } + + public void OnReturnSingle(TResource resource, ResourcePipeline pipeline) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.OnReturn(ToList(resource), pipeline); + } + + public IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) + where TResource : class, IIdentifiable + { + return _resourceHookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); + } + + public object OnReturnRelationship(object resourceOrResources) + { + if (resourceOrResources is IEnumerable enumerable) + { + var resources = enumerable.Cast(); + return _resourceHookExecutor.OnReturn(resources, ResourcePipeline.GetRelationship).ToArray(); + } + + if (resourceOrResources is IIdentifiable identifiable) + { + var resources = ToList(identifiable); + return _resourceHookExecutor.OnReturn(resources, ResourcePipeline.GetRelationship).Single(); + } + + return resourceOrResources; + } + + private static List ToList(TResource resource) + { + return new List {resource}; + } + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs index 49df8d702c..f35540e7d4 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs @@ -51,7 +51,7 @@ public void UpdateUnique(IEnumerable updated) /// /// Reassignment is done according to provided relationships /// - public void Reassign(IResourceFactory resourceFactory, IEnumerable updated = null) + public void Reassign(IEnumerable updated = null) { var unique = (HashSet)UniqueResources; foreach (var group in _relationshipsFromPreviousLayer) @@ -67,13 +67,13 @@ public void Reassign(IResourceFactory resourceFactory, IEnumerable updated = nul { var intersection = relationshipCollection.Intersect(unique, _comparer); IEnumerable typedCollection = TypeHelper.CopyToTypedCollection(intersection, relationshipCollection.GetType()); - proxy.SetValue(left, typedCollection, resourceFactory); + proxy.SetValue(left, typedCollection); } else if (currentValue is IIdentifiable relationshipSingle) { if (!unique.Intersect(new HashSet { relationshipSingle }, _comparer).Any()) { - proxy.SetValue(left, null, resourceFactory); + proxy.SetValue(left, null); } } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs index 5ae502336c..de364e8ccc 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs @@ -1,5 +1,4 @@ using System.Collections; -using JsonApiDotNetCore.Resources; using RightType = System.Type; namespace JsonApiDotNetCore.Hooks.Internal.Traversal @@ -31,7 +30,7 @@ internal interface IResourceNode /// A helper method to assign relationships to the previous layer after firing hooks. /// Or, in case of the root node, to update the original source enumerable. /// - void Reassign(IResourceFactory resourceFactory, IEnumerable source = null); + void Reassign(IEnumerable source = null); /// /// A helper method to internally update the unique set of resources as a result of /// a filter action in a hook. diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs index ab701882e5..054d4c155c 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs @@ -81,8 +81,7 @@ public object GetValue(IIdentifiable resource) /// /// Parent resource. /// The relationship value. - /// - public void SetValue(IIdentifiable resource, object value, IResourceFactory resourceFactory) + public void SetValue(IIdentifiable resource, object value) { if (Attribute is HasManyThroughAttribute hasManyThrough) { @@ -109,7 +108,7 @@ public void SetValue(IIdentifiable resource, object value, IResourceFactory reso return; } - Attribute.SetValue(resource, value, resourceFactory); + Attribute.SetValue(resource, value); } } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs index 3c3103fd52..99a3057841 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs @@ -59,7 +59,7 @@ public void UpdateUnique(IEnumerable updated) _uniqueResources = new HashSet(intersected); } - public void Reassign(IResourceFactory resourceFactory, IEnumerable source = null) + public void Reassign(IEnumerable source = null) { var ids = _uniqueResources.Select(ue => ue.StringId); diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index c0f3e2f6e0..346072ed5c 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -71,6 +71,12 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) return new ErrorDocument(modelStateException.Errors); } + if (exception is SecondaryResourcesNotFoundException + resourcesInRelationshipAssignmentNotFound) + { + return new ErrorDocument(resourcesInRelationshipAssignmentNotFound.Errors); + } + Error error = exception is JsonApiException jsonApiException ? jsonApiException.Error : new Error(HttpStatusCode.InternalServerError) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs index f219241af6..134d8cebdd 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs @@ -19,6 +19,11 @@ public EqualsAnyOfExpression(ResourceFieldChainExpression targetAttribute, { TargetAttribute = targetAttribute ?? throw new ArgumentNullException(nameof(targetAttribute)); Constants = constants ?? throw new ArgumentNullException(nameof(constants)); + + if (constants.Count < 2) + { + throw new ArgumentException("At least two constants are required.", nameof(constants)); + } } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs new file mode 100644 index 0000000000..1204a9fe0d --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -0,0 +1,16 @@ +using System; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// The error that is thrown when the underlying data store is unable to persist changes. + /// + public sealed class DataStoreUpdateException : Exception + { + public DataStoreUpdateException(Exception exception) + : base("Failed to persist changes in the underlying data store.", exception) { } + + public DataStoreUpdateException(string message) + : base(message) { } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index ebc1ec6498..04b418f7dc 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,54 +1,53 @@ using System; using System.Linq; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; namespace JsonApiDotNetCore.Repositories { public static class DbContextExtensions { - /// - /// Determines whether or not EF is already tracking an entity of the same Type and Id - /// and returns that entity. - /// - internal static TEntity GetTrackedEntity(this DbContext context, TEntity entity) - where TEntity : class, IIdentifiable + public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + if (resource == null) throw new ArgumentNullException(nameof(resource)); - var entityEntry = context.ChangeTracker + var trackedIdentifiable = (IIdentifiable)dbContext.GetTrackedIdentifiable(resource); + if (trackedIdentifiable == null) + { + dbContext.Entry(resource).State = EntityState.Unchanged; + trackedIdentifiable = resource; + } + + return trackedIdentifiable; + } + + public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); + + var entityType = identifiable.GetType(); + var entityEntry = dbContext.ChangeTracker .Entries() .FirstOrDefault(entry => - entry.Entity.GetType() == entity.GetType() && - ((IIdentifiable) entry.Entity).StringId == entity.StringId); + entry.Entity.GetType() == entityType && + ((IIdentifiable) entry.Entity).StringId == identifiable.StringId); - return (TEntity) entityEntry?.Entity; + return entityEntry?.Entity; } - - /// - /// Gets the current transaction or creates a new one. - /// If a transaction already exists, commit, rollback and dispose - /// will not be called. It is assumed the creator of the original - /// transaction should be responsible for disposal. - /// - /// - /// - /// - /// using(var transaction = _context.GetCurrentOrCreateTransaction()) - /// { - /// // perform multiple operations on the context and then save... - /// _context.SaveChanges(); - /// } - /// - /// - public static async Task GetCurrentOrCreateTransactionAsync(this DbContext context) + + public static IQueryable Set(this DbContext dbContext, Type entityType) { - if (context == null) throw new ArgumentNullException(nameof(context)); + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + if (entityType == null) throw new ArgumentNullException(nameof(entityType)); + + var getDbSetOpen = typeof(DbContext).GetMethod(nameof(DbContext.Set)); + + var getDbSetGeneric = getDbSetOpen.MakeGenericMethod(entityType); + var dbSet = (IQueryable)getDbSetGeneric.Invoke(dbContext, null); - return await SafeTransactionProxy.GetOrCreateAsync(context.Database); + return dbSet; } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 2131ab8e6c..ccb08b9907 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -2,19 +2,50 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; +using System.Reflection; using System.Threading.Tasks; +using Humanizer; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Repositories.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; +// TODO: Tests that cover relationship updates with required relationships. All relationships right are currently optional. +// - Setting a required relationship to null +// - Creating resource with resource +// - One-to-one required / optional => what is the current behavior? +// tangent: +// - How and where to read EF Core metadata when "required-relationship-error" is triggered? namespace JsonApiDotNetCore.Repositories { + /// + /// Implements the foundational repository implementation that uses Entity Framework Core. + /// + public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository + where TResource : class, IIdentifiable + { + public EntityFrameworkCoreRepository( + ITargetedFields targetedFields, + IDbContextResolver contextResolver, + IResourceGraph resourceGraph, + IResourceFactory resourceFactory, + IEnumerable constraintProviders, + IGetResourcesByIds getResourcesByIds, + ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, getResourcesByIds, loggerFactory) + { } + } + /// /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. /// @@ -24,18 +55,17 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly ITargetedFields _targetedFields; private readonly DbContext _dbContext; private readonly IResourceGraph _resourceGraph; - private readonly IGenericServiceFactory _genericServiceFactory; private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; + private readonly IGetResourcesByIds _getResourcesByIds; private readonly TraceLogWriter> _traceWriter; - public EntityFrameworkCoreRepository( - ITargetedFields targetedFields, + public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IGetResourcesByIds getResourcesByIds, ILoggerFactory loggerFactory) { if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); @@ -43,9 +73,9 @@ public EntityFrameworkCoreRepository( _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); - _genericServiceFactory = genericServiceFactory ?? throw new ArgumentNullException(nameof(genericServiceFactory)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); + _getResourcesByIds = getResourcesByIds ?? throw new ArgumentNullException(nameof(getResourcesByIds)); _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -57,6 +87,7 @@ public virtual async Task> GetAsync(QueryLayer la if (layer == null) throw new ArgumentNullException(nameof(layer)); IQueryable query = ApplyQueryLayer(layer); + return await query.ToListAsync(); } @@ -80,6 +111,12 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) _traceWriter.LogMethodStart(new {layer}); if (layer == null) throw new ArgumentNullException(nameof(layer)); + if (EntityFrameworkCoreSupport.Version.Major < 5) + { + var writer = new MemoryLeakDetectionBugRewriter(); + layer = writer.Rewrite(layer); + } + IQueryable source = GetAll(); var queryableHandlers = _constraintProviders @@ -100,7 +137,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) var expression = builder.ApplyQuery(layer); return source.Provider.CreateQuery(expression); } - + protected virtual IQueryable GetAll() { return _dbContext.Set(); @@ -112,313 +149,473 @@ public virtual async Task CreateAsync(TResource resource) _traceWriter.LogMethodStart(new {resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - foreach (var relationshipAttr in _targetedFields.Relationships) + foreach (var relationship in _targetedFields.Relationships) { - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, resource, out bool relationshipWasAlreadyTracked); - LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); - if (relationshipWasAlreadyTracked || relationshipAttr is HasManyThroughAttribute) - // We only need to reassign the relationship value to the to-be-added - // resource when we're using a different instance of the relationship (because this different one - // was already tracked) than the one assigned to the to-be-created resource. - // Alternatively, even if we don't have to reassign anything because of already tracked - // entities, we still need to assign the "through" entities in the case of many-to-many. - relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); + var rightValue = relationship.GetValue(resource); + await ApplyRelationshipUpdate(relationship, resource, rightValue); } - + _dbContext.Set().Add(resource); - await _dbContext.SaveChangesAsync(); + await SaveChangesAsync(); FlushFromCache(resource); - - // this ensures relationships get reloaded from the database if they have - // been requested. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 - DetachRelationships(resource); } - /// - /// Loads the inverse relationships to prevent foreign key constraints from being violated - /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - /// - /// Consider the following example: - /// person.todoItems = [t1,t2] is updated to [t3, t4]. If t3, and/or t4 was - /// already related to a other person, and these persons are NOT loaded into the - /// DbContext, then the query may cause a foreign key constraint. Loading - /// these "inverse relationships" into the DB context ensures EF core to take - /// this into account. - /// - /// - private void LoadInverseRelationships(object trackedRelationshipValue, RelationshipAttribute relationshipAttr) + /// + public virtual async Task AddToToManyRelationshipAsync(TId id, ISet secondaryResourceIds) { - if (relationshipAttr.InverseNavigation == null || trackedRelationshipValue == null) return; - if (relationshipAttr is HasOneAttribute hasOneAttr) + _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); + + var relationship = _targetedFields.Relationships.Single(); + + if (relationship is HasManyThroughAttribute hasManyThroughRelationship) { - var relationEntry = _dbContext.Entry((IIdentifiable)trackedRelationshipValue); - if (IsHasOneRelationship(hasOneAttr.InverseNavigation, trackedRelationshipValue.GetType())) - relationEntry.Reference(hasOneAttr.InverseNavigation).Load(); - else - relationEntry.Collection(hasOneAttr.InverseNavigation).Load(); + // In the case of many-to-many relationships, creating a duplicate entry in the join table results in a uniqueness constraint violation. + await RemoveAlreadyRelatedResourcesFromAssignment(hasManyThroughRelationship, id, secondaryResourceIds); } - else if (relationshipAttr is HasManyAttribute hasManyAttr && !(relationshipAttr is HasManyThroughAttribute)) + + var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); + + if (secondaryResourceIds.Any()) { - foreach (IIdentifiable relationshipValue in (IEnumerable)trackedRelationshipValue) - _dbContext.Entry(relationshipValue).Reference(hasManyAttr.InverseNavigation).Load(); + await ApplyRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); + await SaveChangesAsync(); } } - private bool IsHasOneRelationship(string internalRelationshipName, Type type) + /// + public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceIds) { - var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.Property.Name == internalRelationshipName); - if (relationshipAttr != null) - { - if (relationshipAttr is HasOneAttribute) - return true; + _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); - return false; - } - // relationshipAttr is null when we don't put a [RelationshipAttribute] on the inverse navigation property. - // In this case we use reflection to figure out what kind of relationship is pointing back. - return !TypeHelper.IsOrImplementsInterface(type.GetProperty(internalRelationshipName).PropertyType, typeof(IEnumerable)); + var primaryResource = await GetPrimaryResourceForCompleteReplacement(id, _targetedFields.Relationships); + + var relationship = _targetedFields.Relationships.Single(); + + await ApplyRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); + + await SaveChangesAsync(); } - private void DetachRelationships(TResource resource) + /// + public virtual async Task UpdateAsync(TResource resource) { + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + var resourceFromDatabase = await GetPrimaryResourceForCompleteReplacement(resource.Id, _targetedFields.Relationships); + foreach (var relationship in _targetedFields.Relationships) { - var value = relationship.GetValue(resource); - if (value == null) - continue; + var rightResources = relationship.GetValue(resource); + await ApplyRelationshipUpdate(relationship, resourceFromDatabase, rightResources); + } - if (value is IEnumerable collection) - { - foreach (IIdentifiable single in collection) - _dbContext.Entry(single).State = EntityState.Detached; - // detaching has many relationships is not sufficient to - // trigger a full reload of relationships: the navigation - // property actually needs to be nulled out, otherwise - // EF will still add duplicate instances to the collection - relationship.SetValue(resource, null, _resourceFactory); - } - else - { - _dbContext.Entry(value).State = EntityState.Detached; - } + foreach (var attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceFromDatabase, attribute.GetValue(resource)); } + + await SaveChangesAsync(); + + FlushFromCache(resourceFromDatabase); } /// - public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource) + public virtual async Task DeleteAsync(TId id) { - _traceWriter.LogMethodStart(new {requestResource, databaseResource}); - if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); - if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); + _traceWriter.LogMethodStart(new {id}); - foreach (var attribute in _targetedFields.Attributes) - attribute.SetValue(databaseResource, attribute.GetValue(requestResource)); + var resource = (TResource)_dbContext.GetTrackedOrAttach(CreatePrimaryResourceWithAssignedId(id)); - foreach (var relationshipAttr in _targetedFields.Relationships) + foreach (var relationship in _resourceGraph.GetRelationships()) { - // loads databasePerson.todoItems - LoadCurrentRelationships(databaseResource, relationshipAttr); - // trackedRelationshipValue is either equal to updatedPerson.todoItems, - // or replaced with the same set (same ids) of todoItems from the EF Core change tracker, - // which is the case if they were already tracked - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestResource, out _); - // loads into the db context any persons currently related - // to the todoItems in trackedRelationshipValue - LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); - // assigns the updated relationship to the database resource - //AssignRelationshipValue(databaseResource, trackedRelationshipValue, relationshipAttr); - relationshipAttr.SetValue(databaseResource, trackedRelationshipValue, _resourceFactory); + if (ShouldLoadRelationshipForSafeDeletion(relationship)) + { + var navigation = GetNavigationEntry(resource, relationship); + await navigation.LoadAsync(); + } } - await _dbContext.SaveChangesAsync(); + _dbContext.Remove(resource); + + await SaveChangesAsync(); } /// - /// Responsible for getting the relationship value for a given relationship - /// attribute of a given resource. It ensures that the relationship value - /// that it returns is attached to the database without reattaching duplicates instances - /// to the change tracker. It does so by checking if there already are - /// instances of the to-be-attached entities in the change tracker. + /// Loads the data of the relationship if in EF Core it is configured in such a way that loading the related + /// entities into memory is required for successfully executing the selected deletion behavior. /// - private object GetTrackedRelationshipValue(RelationshipAttribute relationshipAttr, TResource resource, out bool wasAlreadyAttached) + private bool ShouldLoadRelationshipForSafeDeletion(RelationshipAttribute relationship) { - wasAlreadyAttached = false; - if (relationshipAttr is HasOneAttribute hasOneAttr) - { - var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(resource); - if (relationshipValue == null) - return null; - return GetTrackedHasOneRelationshipValue(relationshipValue, ref wasAlreadyAttached); - } + var navigationMeta = GetNavigationMetadata(relationship); + var clientIsResponsibleForClearingForeignKeys = navigationMeta?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; + + var isPrincipalSide = !HasForeignKeyAtLeftSide(relationship); - IEnumerable relationshipValues = (IEnumerable)relationshipAttr.GetValue(resource); - if (relationshipValues == null) - return null; + return isPrincipalSide && clientIsResponsibleForClearingForeignKeys; + } - return GetTrackedManyRelationshipValue(relationshipValues, relationshipAttr, ref wasAlreadyAttached); + private INavigation GetNavigationMetadata(RelationshipAttribute relationship) + { + return _dbContext.Model.FindEntityType(typeof(TResource)).FindNavigation(relationship.Property.Name); } - // helper method used in GetTrackedRelationshipValue. See comments below. - private IEnumerable GetTrackedManyRelationshipValue(IEnumerable relationshipValues, RelationshipAttribute relationshipAttr, ref bool wasAlreadyAttached) + /// + public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet secondaryResourceIds) { - if (relationshipValues == null) return null; - bool newWasAlreadyAttached = false; + _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); - var trackedPointerCollection = TypeHelper.CopyToTypedCollection(relationshipValues.Select(pointer => + var primaryResource = await GetPrimaryResourceForCompleteReplacement(id, _targetedFields.Relationships); + + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + await AssertSecondaryResourcesExist(secondaryResourceIds, relationship); + + var rightResources = ((IEnumerable)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); + rightResources.ExceptWith(secondaryResourceIds); + + await ApplyRelationshipUpdate(relationship, primaryResource, rightResources); + await SaveChangesAsync(); + } + + private async Task SaveChangesAsync() + { + try { - var tracked = AttachOrGetTracked(pointer); - if (tracked != null) newWasAlreadyAttached = true; + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateException exception) + { + throw new DataStoreUpdateException(exception); + } + } + + private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) + { + var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); - var trackedPointer = tracked ?? pointer; - - // We should recalculate the target type for every iteration because types may vary. This is possible with resource inheritance. - return Convert.ChangeType(trackedPointer, trackedPointer.GetType()); - }), relationshipAttr.Property.PropertyType); + if (ShouldLoadInverseRelationship(relationship, trackedValueToAssign)) + { + var entityEntry = _dbContext.Entry(trackedValueToAssign); + var inversePropertyName = relationship.InverseNavigationProperty.Name; + + await entityEntry.Reference(inversePropertyName).LoadAsync(); + } + + if (HasForeignKeyAtLeftSide(relationship) && trackedValueToAssign == null) + { + PrepareChangeTrackerForNullAssignment(relationship, leftResource); + } + + relationship.SetValue(leftResource, trackedValueToAssign); + } - if (newWasAlreadyAttached) wasAlreadyAttached = true; + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) + { + if (relationship is HasOneAttribute) + { + var navigation = GetNavigationMetadata(relationship); - return trackedPointerCollection; + return navigation.IsDependentToPrincipal(); + } + + return false; } - // helper method used in GetTrackedRelationshipValue. See comments there. - private IIdentifiable GetTrackedHasOneRelationshipValue(IIdentifiable relationshipValue, ref bool wasAlreadyAttached) + private TResource CreatePrimaryResourceWithAssignedId(TId id) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + return resource; + } + + private void FlushFromCache(IIdentifiable resource) { - var tracked = AttachOrGetTracked(relationshipValue); - if (tracked != null) wasAlreadyAttached = true; - return tracked ?? relationshipValue; + resource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); + if (resource != null) + { + DetachEntities(new [] { resource }); + DetachRelationships(resource); + } } - /// - public async Task UpdateRelationshipAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) + private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute relationship, TId primaryResourceId, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new {parent, relationship, relationshipIds}); - if (parent == null) throw new ArgumentNullException(nameof(parent)); - if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); + // TODO: Finalize this. + var throughEntitiesFilter = new ThroughEntitiesFilter(_dbContext, relationship); + var typedRightIds = secondaryResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); + var throughEntities = await throughEntitiesFilter.GetBy(primaryResourceId, typedRightIds); + + // Alternative approaches: + // throughEntities = await GetFilteredThroughEntities_DynamicQueryBuilding(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); + // throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); + + var rightResources = throughEntities.Select(ConstructRightResourceOfHasManyRelationship).ToHashSet(); + secondaryResourceIds.ExceptWith(rightResources.ToHashSet()); + + DetachEntities(throughEntities); + } - var typeToUpdate = relationship is HasManyThroughAttribute hasManyThrough - ? hasManyThrough.ThroughType - : relationship.RightType; + private IIdentifiable ConstructRightResourceOfHasManyRelationship(object entity) + { + var relationship = (HasManyThroughAttribute)_targetedFields.Relationships.Single(); - var helper = _genericServiceFactory.Get(typeof(RepositoryRelationshipUpdateHelper<>), typeToUpdate); - await helper.UpdateRelationshipAsync((IIdentifiable)parent, relationship, relationshipIds); + var rightResource = _resourceFactory.CreateInstance(relationship.RightType); + rightResource.StringId = relationship.RightIdProperty.GetValue(entity).ToString(); - await _dbContext.SaveChangesAsync(); + return rightResource; } - /// - public virtual async Task DeleteAsync(TId id) + private async Task GetFilteredThroughEntities_DynamicQueryBuilding(TId leftId, ISet rightIds, HasManyThroughAttribute relationship) { - _traceWriter.LogMethodStart(new {id}); + var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); + + var filter = ThroughEntitiesFilter.GetEqualsAndContainsFilter(leftId, rightIds, relationship, throughEntityParameter); - var resourceToDelete = _resourceFactory.CreateInstance(); - resourceToDelete.Id = id; + var predicate = Expression.Lambda(filter, throughEntityParameter); - var resourceFromCache = _dbContext.GetTrackedEntity(resourceToDelete); - if (resourceFromCache != null) + IQueryable throughSource = _dbContext.Set(relationship.ThroughType); + var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, predicate); + + dynamic query = throughSource.Provider.CreateQuery(whereClause); + IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); + + return result.Cast().ToArray(); + } + + private async Task GetFilteredThroughEntities_QueryBuilderCall(TId leftId, ISet rightIds, HasManyThroughAttribute relationship) + { + var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); + var comparisionId = new LiteralConstantExpression(leftId.ToString()); + FilterExpression equalsFilter = new ComparisonExpression(ComparisonOperator.Equals, comparisonTargetField, comparisionId); + + var equalsAnyOfTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty }); + var equalsAnyOfIds = rightIds.Select(r => new LiteralConstantExpression(r.ToString())).ToArray(); + FilterExpression containsFilter = new EqualsAnyOfExpression(equalsAnyOfTargetField, equalsAnyOfIds); + + var filter = new LogicalExpression(LogicalOperator.And, new QueryExpression[] { equalsFilter, containsFilter } ); + + IQueryable throughSource = _dbContext.Set(relationship.ThroughType); + + var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); + var scope = scopeFactory.CreateScope(relationship.ThroughType); + + var whereClauseBuilder = new WhereClauseBuilder(throughSource.Expression, scope, typeof(Queryable)); + var whereClause = whereClauseBuilder.ApplyWhere(filter); + + dynamic query = throughSource.Provider.CreateQuery(whereClause); + IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); + + return result.Cast().ToArray(); + } + + private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) + { + EntityEntry entityEntry = _dbContext.Entry(resource); + + switch (relationship) { - resourceToDelete = resourceFromCache; + case HasManyAttribute hasManyRelationship: + { + return entityEntry.Collection(hasManyRelationship.Property.Name); + } + case HasOneAttribute hasOneRelationship: + { + return entityEntry.Reference(hasOneRelationship.Property.Name); + } } - else + + return null; + } + + /// + /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + /// + private bool ShouldLoadInverseRelationship(RelationshipAttribute relationship, object trackedValueToAssign) + { + return trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship); + } + + private bool IsOneToOneRelationship(RelationshipAttribute relationship) + { + if (relationship is HasOneAttribute hasOneRelationship) { - _dbContext.Attach(resourceToDelete); + var elementType = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); + return elementType == null; } - _dbContext.Remove(resourceToDelete); + return false; + } - try + /// + /// If a (shadow) foreign key is already loaded on the left resource of a relationship, it is not possible to + /// set it to null by just assigning null to the navigation property and marking it as modified. + /// Instead, when marking it as modified, it will mark the pre-existing foreign key value as modified too but without setting its value to null. + /// One way to work around this is by loading the relationship before setting it to null. Another approach (as done in this method) is + /// tricking the change tracker into recognizing the null assignment by first assigning a placeholder entity to the navigation property, and then + /// setting it to null. + /// + private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relationship, TResource leftResource) + { + var placeholderRightResource = _resourceFactory.CreateInstance(relationship.RightType); + + // When assigning a related entity to a navigation property, it will be attached to the change tracker. + // This fails when that entity has null reference(s) for its primary key(s). + EnsurePrimaryKeyPropertiesAreNotNull(placeholderRightResource); + + relationship.SetValue(leftResource, placeholderRightResource); + _dbContext.Entry(leftResource).DetectChanges(); + + DetachEntities(new [] { placeholderRightResource }); + } + + private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) + { + var primaryKey = _dbContext.Entry(entity).Metadata.FindPrimaryKey(); + if (primaryKey != null) { - await _dbContext.SaveChangesAsync(); - return true; + foreach (var property in primaryKey.Properties) + { + var propertyValue = TryGetValueForProperty(property.PropertyInfo); + if (propertyValue != null) + { + property.PropertyInfo.SetValue(entity, propertyValue); + } + } + } + } + + private object TryGetValueForProperty(PropertyInfo propertyInfo) + { + var propertyType = propertyInfo.PropertyType; + + if (propertyType == typeof(string)) + { + return string.Empty; } - catch (DbUpdateConcurrencyException) + + if (Nullable.GetUnderlyingType(propertyType) != null) { - return false; + var underlyingType = propertyInfo.PropertyType.GetGenericArguments()[0]; + // TODO: Write test with primary key property type int? or equivalent. + return Activator.CreateInstance(underlyingType); } + + if (!propertyType.IsValueType) + { + throw new InvalidOperationException($"Unexpected reference type '{propertyType.Name}' for primary key property '{propertyInfo.Name}'."); + } + + return null; } - /// - public virtual void FlushFromCache(TResource resource) + private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) { - _traceWriter.LogMethodStart(new {resource}); - if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (valueToAssign is IReadOnlyCollection rightResourcesInToManyRelationship) + { + return EnsureToManyRelationshipValueToAssignIsTracked(rightResourcesInToManyRelationship, relationshipPropertyType); + } - _dbContext.Entry(resource).State = EntityState.Detached; + if (valueToAssign is IIdentifiable rightResourceInToOneRelationship) + { + return _dbContext.GetTrackedOrAttach(rightResourceInToOneRelationship); + } + + return null; + } + + private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyCollection rightResources, Type rightCollectionType) + { + var rightResourcesTracked = new object[rightResources.Count]; + + int index = 0; + foreach (var rightResource in rightResources) + { + rightResourcesTracked[index] = _dbContext.GetTrackedOrAttach(rightResource); + index++; + } + + return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } /// - /// Before assigning new relationship values (UpdateAsync), we need to - /// attach the current database values of the relationship to the dbContext, else - /// it will not perform a complete-replace which is required for - /// one-to-many and many-to-many. - /// + /// Gets the primary resource by id and performs side-loading of data such that EF Core correctly performs complete replacements of relationships. + /// + /// /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. - /// If we want to update this todo-item set to `t3` and `t4`, simply assigning + /// If we want to update this set to `t3` and `t4`, simply assigning /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, - /// after which the reassignment `p1.todoItems = [t3, t4]` will actually - /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. - /// - protected void LoadCurrentRelationships(TResource oldResource, RelationshipAttribute relationshipAttribute) + /// after which the reassignment `p1.todoItems = [t3, t4]` will actually + /// make EF Core perform a complete replacement. This method does the loading of `[t1, t2]`. + /// + private async Task GetPrimaryResourceForCompleteReplacement(TId id, ISet relationships) { - if (oldResource == null) throw new ArgumentNullException(nameof(oldResource)); - if (relationshipAttribute == null) throw new ArgumentNullException(nameof(relationshipAttribute)); + TResource primaryResource; - if (relationshipAttribute is HasManyThroughAttribute throughAttribute) + if (relationships.Any()) { - _dbContext.Entry(oldResource).Collection(throughAttribute.ThroughProperty.Name).Load(); + var query = _dbContext.Set().Where(resource => resource.Id.Equals(id)); + foreach (var relationship in relationships) + { + query = query.Include(relationship.RelationshipPath); + } + + primaryResource = query.FirstOrDefault(); } - else if (relationshipAttribute is HasManyAttribute hasManyAttribute) + else { - _dbContext.Entry(oldResource).Collection(hasManyAttribute.Property.Name).Load(); + primaryResource = await _dbContext.FindAsync(id); } + + if (primaryResource == null) + { + throw new DataStoreUpdateException($"Resource of type '{typeof(TResource)}' with id '{id}' does not exist."); + } + + return primaryResource; } - /// - /// Given a IIdentifiable relationship value, verify if a resource of the underlying - /// type with the same ID is already attached to the dbContext, and if so, return it. - /// If not, attach the relationship value to the dbContext. - /// - /// useful article: https://stackoverflow.com/questions/30987806/dbset-attachentity-vs-dbcontext-entryentity-state-entitystate-modified - /// - private IIdentifiable AttachOrGetTracked(IIdentifiable relationshipValue) + private async Task AssertSecondaryResourcesExist(ISet secondaryResourceIds, HasManyAttribute relationship) { - var trackedEntity = _dbContext.GetTrackedEntity(relationshipValue); + var typedIds = secondaryResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); + var secondaryResourcesFromDatabase = await _getResourcesByIds.Get(relationship.RightType, typedIds); - if (trackedEntity != null) + if (secondaryResourcesFromDatabase.Count < secondaryResourceIds.Count) { - // there already was an instance of this type and ID tracked - // by EF Core. Reattaching will produce a conflict, so from now on we - // will use the already attached instance instead. This entry might - // contain updated fields as a result of business logic elsewhere in the application - return trackedEntity; + throw new DataStoreUpdateException($"One or more related resources of type '{relationship.RightType}' do not exist."); } - // the relationship pointer is new to EF Core, but we are sure - // it exists in the database, so we attach it. In this case, as per - // the json:api spec, we can also safely assume that no fields of - // this resource were updated. - _dbContext.Entry(relationshipValue).State = EntityState.Unchanged; - return null; + DetachEntities(secondaryResourcesFromDatabase.ToArray()); } - } - /// - /// Implements the foundational repository implementation that uses Entity Framework Core. - /// - public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository - where TResource : class, IIdentifiable - { - public EntityFrameworkCoreRepository( - ITargetedFields targetedFields, - IDbContextResolver contextResolver, - IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, - IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) - { } + private void DetachRelationships(IIdentifiable resource) + { + foreach (var relationship in _targetedFields.Relationships) + { + var rightValue = relationship.GetValue(resource); + + if (rightValue is IEnumerable rightResources) + { + DetachEntities(rightResources.ToArray()); + } + else if (rightValue != null) + { + DetachEntities(new [] { rightValue }); + _dbContext.Entry(rightValue).State = EntityState.Detached; + } + } + } + + private void DetachEntities(IEnumerable entities) + { + foreach (var entity in entities) + { + _dbContext.Entry(entity).State = EntityState.Detached; + } + } } } diff --git a/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs deleted file mode 100644 index 376644cb2e..0000000000 --- a/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Repositories -{ - /// - /// A special helper that processes updates of relationships - /// - /// - /// This service required to be able translate involved expressions into queries - /// instead of having them evaluated on the client side. In particular, for all three types of relationship - /// a lookup is performed based on an ID. Expressions that use IIdentifiable.StringId can never - /// be translated into queries because this property only exists at runtime after the query is performed. - /// We will have to build expression trees if we want to use IIdentifiable{TId}.TId, for which we minimally a - /// generic execution to DbContext.Set{T}(). - /// - public interface IRepositoryRelationshipUpdateHelper - { - /// - /// Processes updates of relationships - /// - Task UpdateRelationshipAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); - } -} diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs index 81efe34e54..f5ae656556 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -2,20 +2,24 @@ namespace JsonApiDotNetCore.Repositories { - /// - public interface IResourceRepository - : IResourceRepository + /// + /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. + /// + /// The resource type. + public interface IResourceRepository + : IResourceRepository, IResourceReadRepository, IResourceWriteRepository where TResource : class, IIdentifiable - { } + { + } /// /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. /// /// The resource type. /// The resource identifier type. - public interface IResourceRepository - : IResourceReadRepository, - IResourceWriteRepository + public interface IResourceRepository + : IResourceReadRepository, IResourceWriteRepository where TResource : class, IIdentifiable - { } + { + } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs new file mode 100644 index 0000000000..38e52e66f1 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Retrieves a instance from the D/I container and invokes a callback on it. + /// + public interface IResourceRepositoryAccessor + { + /// + /// Invokes for the specified resource type. + /// + Task> GetAsync(Type resourceType, QueryLayer layer); + } +} diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 27e46af19e..db6fa3a8c5 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Repositories { @@ -25,27 +24,28 @@ public interface IResourceWriteRepository Task CreateAsync(TResource resource); /// - /// Updates an existing resource in the underlying data store. + /// Adds resources to a to-many relationship in the underlying data store. /// - /// The (partial) resource coming from the request body. - /// The resource as stored in the database before the update. - Task UpdateAsync(TResource requestResource, TResource databaseResource); + Task AddToToManyRelationshipAsync(TId id, ISet secondaryResourceIds); /// - /// Updates a relationship in the underlying data store. + /// Updates the attributes and relationships of an existing resource in the underlying data store. /// - Task UpdateRelationshipAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); + Task UpdateAsync(TResource resource); /// - /// Deletes a resource from the underlying data store. + /// Performs a complete replacement of the relationship in the underlying data store. /// - /// Identifier for the resource to delete. - /// true if the resource was deleted; false is the resource did not exist. - Task DeleteAsync(TId id); - + Task SetRelationshipAsync(TId id, object secondaryResourceIds); + + /// + /// Deletes an existing resource from the underlying data store. + /// + Task DeleteAsync(TId id); + /// - /// Ensures that the next time this resource is requested, it is re-fetched from the underlying data store. + /// Removes resources from a to-many relationship in the underlying data store. /// - void FlushFromCache(TResource resource); + Task RemoveFromToManyRelationshipAsync(TId id, ISet secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs b/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs new file mode 100644 index 0000000000..aec79b170d --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; +using Humanizer; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Repositories.Internal +{ + // TODO: Refactor this type (it is a helper method). + internal sealed class ThroughEntitiesFilter + { + private readonly DbContext _dbContext; + private readonly HasManyThroughAttribute _relationship; + + internal ThroughEntitiesFilter(DbContext dbContext, HasManyThroughAttribute relationship) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _relationship = relationship ?? throw new ArgumentNullException(nameof(relationship)); + } + + public async Task GetBy(object primaryId, ISet secondaryIds) + { + + var throughEntityParameter = Expression.Parameter(_relationship.ThroughType, _relationship.ThroughType.Name.Camelize()); + var filter = GetEqualsAndContainsFilter(primaryId, secondaryIds, _relationship, throughEntityParameter); + + dynamic runtimeTypeParameter = TypeHelper.CreateInstance(_relationship.ThroughType); + dynamic @this = this; + + return await @this.GetFilteredEntities(runtimeTypeParameter, throughEntityParameter, filter); + } + + private async Task GetFilteredEntities(TThroughType _, ParameterExpression parameter, Expression filter) where TThroughType : class + { + var predicate = Expression.Lambda>(filter, parameter); + var result = await _dbContext.Set().Where(predicate).ToListAsync(); + + return result.Cast().ToArray(); + } + + internal static Expression GetEqualsAndContainsFilter(object idToEqual, ISet idsToContain, + HasManyThroughAttribute relationship, ParameterExpression parameter) + { + var idEqualsFilter = GetEqualsCall(idToEqual, parameter, relationship.LeftIdProperty); + var containsIdFilter = GetContainsCall(idsToContain, parameter, relationship.RightIdProperty); + + return Expression.AndAlso(idEqualsFilter, containsIdFilter); + } + + internal static MethodCallExpression GetContainsCall(ISet secondaryResourceIds, + ParameterExpression rightEntityParameter, PropertyInfo rightIdProperty) + { + var rightIdMember = Expression.Property(rightEntityParameter, rightIdProperty.Name); + + var idType = rightIdProperty.PropertyType; + var typedIds = TypeHelper.CopyToList(secondaryResourceIds, idType); + var idCollectionConstant = Expression.Constant(typedIds); + + var containsCall = Expression.Call( + typeof(Enumerable), + nameof(Enumerable.Contains), + new[] {idType}, + idCollectionConstant, + rightIdMember); + + return containsCall; + } + + internal static BinaryExpression GetEqualsCall(object id, ParameterExpression rightEntityParameter, + PropertyInfo leftIdProperty) + { + var leftIdMember = Expression.Property(rightEntityParameter, leftIdProperty.Name); + var idConstant = Expression.Constant(id, id.GetType()); + + return Expression.Equal(leftIdMember, idConstant); + } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs b/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs new file mode 100644 index 0000000000..bf26979ad7 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Removes projections from a when its resource type uses injected parameters, + /// as a workaround for EF Core bug https://github.com/dotnet/efcore/issues/20502, which exists in versions below v5. + /// + /// + /// Note that by using this workaround, nested filtering, paging and sorting all remain broken in EF Core 3.1 when using injected parameters in resources. + /// But at least it enables simple top-level queries to succeed without an exception. + /// + public sealed class MemoryLeakDetectionBugRewriter + { + public QueryLayer Rewrite(QueryLayer queryLayer) + { + if (queryLayer == null) throw new ArgumentNullException(nameof(queryLayer)); + + return RewriteLayer(queryLayer); + } + + private QueryLayer RewriteLayer(QueryLayer queryLayer) + { + if (queryLayer != null) + { + queryLayer.Projection = RewriteProjection(queryLayer.Projection, queryLayer.ResourceContext); + } + + return queryLayer; + } + + private IDictionary RewriteProjection(IDictionary projection, ResourceContext resourceContext) + { + if (projection == null || projection.Count == 0) + { + return projection; + } + + var newProjection = new Dictionary(); + foreach (var (field, layer) in projection) + { + var newLayer = RewriteLayer(layer); + newProjection.Add(field, newLayer); + } + + if (!ResourceFactory.HasSingleConstructorWithoutParameters(resourceContext.ResourceType)) + { + return null; + } + + return newProjection; + } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs deleted file mode 100644 index d349abe17c..0000000000 --- a/src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Threading.Tasks; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCore.Repositories -{ - /// - public class RepositoryRelationshipUpdateHelper : IRepositoryRelationshipUpdateHelper where TRelatedResource : class - { - private readonly IResourceFactory _resourceFactory; - private readonly DbContext _context; - - public RepositoryRelationshipUpdateHelper(IDbContextResolver contextResolver, IResourceFactory resourceFactory) - { - if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); - - _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); - _context = contextResolver.GetContext(); - } - - /// - public virtual async Task UpdateRelationshipAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) - { - if (parent == null) throw new ArgumentNullException(nameof(parent)); - if (relationship == null) throw new ArgumentNullException(nameof(relationship)); - if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); - - if (relationship is HasManyThroughAttribute hasManyThrough) - await UpdateManyToManyAsync(parent, hasManyThrough, relationshipIds); - else if (relationship is HasManyAttribute) - await UpdateOneToManyAsync(parent, relationship, relationshipIds); - else - await UpdateOneToOneAsync(parent, relationship, relationshipIds); - } - - private async Task UpdateOneToOneAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) - { - TRelatedResource value = null; - if (relationshipIds.Any()) - { // newOwner.id - var target = Expression.Constant(TypeHelper.ConvertType(relationshipIds.First(), TypeHelper.GetIdType(relationship.RightType))); - // (Person p) => ... - ParameterExpression parameter = Expression.Parameter(typeof(TRelatedResource)); - // (Person p) => p.Id - Expression idMember = Expression.Property(parameter, nameof(Identifiable.Id)); - // newOwner.Id.Equals(p.Id) - Expression callEquals = Expression.Call(idMember, nameof(object.Equals), null, target); - var equalsLambda = Expression.Lambda>(callEquals, parameter); - value = await _context.Set().FirstOrDefaultAsync(equalsLambda); - } - relationship.SetValue(parent, value, _resourceFactory); - } - - private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) - { - IEnumerable value; - if (!relationshipIds.Any()) - { - var collectionType = TypeHelper.ToConcreteCollectionType(relationship.Property.PropertyType); - value = (IEnumerable)TypeHelper.CreateInstance(collectionType); - } - else - { - var idType = TypeHelper.GetIdType(relationship.RightType); - var typedIds = TypeHelper.CopyToList(relationshipIds, idType, stringId => TypeHelper.ConvertType(stringId, idType)); - - // [1, 2, 3] - var target = Expression.Constant(typedIds); - // (Person p) => ... - ParameterExpression parameter = Expression.Parameter(typeof(TRelatedResource)); - // (Person p) => p.Id - Expression idMember = Expression.Property(parameter, nameof(Identifiable.Id)); - // [1,2,3].Contains(p.Id) - var callContains = Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] { idMember.Type }, target, idMember); - var containsLambda = Expression.Lambda>(callContains, parameter); - - var resultSet = await _context.Set().Where(containsLambda).ToListAsync(); - value = TypeHelper.CopyToTypedCollection(resultSet, relationship.Property.PropertyType); - } - - relationship.SetValue(parent, value, _resourceFactory); - } - - private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAttribute relationship, IReadOnlyCollection relationshipIds) - { - // we need to create a transaction for the HasManyThrough case so we can get and remove any existing - // through resources and only commit if all operations are successful - var transaction = await _context.GetCurrentOrCreateTransactionAsync(); - // ArticleTag - ParameterExpression parameter = Expression.Parameter(relationship.ThroughType); - // ArticleTag.ArticleId - Expression idMember = Expression.Property(parameter, relationship.LeftIdProperty); - // article.Id - var parentId = TypeHelper.ConvertType(parent.StringId, relationship.LeftIdProperty.PropertyType); - Expression target = Expression.Constant(parentId); - // ArticleTag.ArticleId.Equals(article.Id) - Expression callEquals = Expression.Call(idMember, "Equals", null, target); - var lambda = Expression.Lambda>(callEquals, parameter); - // TODO: we shouldn't need to do this instead we should try updating the existing? - // the challenge here is if a composite key is used, then we will fail to - // create due to a unique key violation - var oldLinks = _context - .Set() - .Where(lambda.Compile()) - .ToList(); - - _context.RemoveRange(oldLinks); - - var newLinks = relationshipIds.Select(x => - { - var link = _resourceFactory.CreateInstance(relationship.ThroughType); - relationship.LeftIdProperty.SetValue(link, TypeHelper.ConvertType(parentId, relationship.LeftIdProperty.PropertyType)); - relationship.RightIdProperty.SetValue(link, TypeHelper.ConvertType(x, relationship.RightIdProperty.PropertyType)); - return link; - }); - - _context.AddRange(newLinks); - await _context.SaveChangesAsync(); - await transaction.CommitAsync(); - } - } -} diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs new file mode 100644 index 0000000000..63644f301f --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Repositories +{ + /// + public class ResourceRepositoryAccessor : IResourceRepositoryAccessor + { + private readonly IServiceProvider _serviceProvider; + private readonly IResourceContextProvider _resourceContextProvider; + + public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceContextProvider resourceContextProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentException(nameof(serviceProvider)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentException(nameof(serviceProvider)); + } + + /// + public async Task> GetAsync(Type resourceType, QueryLayer layer) + { + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (layer == null) throw new ArgumentNullException(nameof(layer)); + + dynamic repository = GetRepository(resourceType); + return (IReadOnlyCollection) await repository.GetAsync(layer); + } + + protected object GetRepository(Type resourceType) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + + if (resourceContext.IdentityType == typeof(int)) + { + var intRepositoryType = typeof(IResourceReadRepository<>).MakeGenericType(resourceContext.ResourceType); + var intRepository = _serviceProvider.GetService(intRepositoryType); + + if (intRepository != null) + { + return intRepository; + } + } + + var resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + return _serviceProvider.GetRequiredService(resourceDefinitionType); + } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs b/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs deleted file mode 100644 index 36c1e39b40..0000000000 --- a/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; - -namespace JsonApiDotNetCore.Repositories -{ - /// - /// Gets the current transaction or creates a new one. - /// If a transaction already exists, commit, rollback and dispose - /// will not be called. It is assumed the creator of the original - /// transaction should be responsible for disposal. - /// - internal class SafeTransactionProxy : IDbContextTransaction - { - private readonly bool _shouldExecute; - private readonly IDbContextTransaction _transaction; - - private SafeTransactionProxy(IDbContextTransaction transaction, bool shouldExecute) - { - _transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); - _shouldExecute = shouldExecute; - } - - public static async Task GetOrCreateAsync(DatabaseFacade databaseFacade) - { - if (databaseFacade == null) throw new ArgumentNullException(nameof(databaseFacade)); - - return databaseFacade.CurrentTransaction != null - ? new SafeTransactionProxy(databaseFacade.CurrentTransaction, shouldExecute: false) - : new SafeTransactionProxy(await databaseFacade.BeginTransactionAsync(), shouldExecute: true); - } - - /// - public Guid TransactionId => _transaction.TransactionId; - - /// - public void Commit() => Proxy(t => t.Commit()); - - /// - public void Rollback() => Proxy(t => t.Rollback()); - - /// - public void Dispose() => Proxy(t => t.Dispose()); - - private void Proxy(Action action) - { - if(_shouldExecute) - action(_transaction); - } - - public Task CommitAsync(CancellationToken cancellationToken = default) - { - return _transaction.CommitAsync(cancellationToken); - } - - public Task RollbackAsync(CancellationToken cancellationToken = default) - { - return _transaction.RollbackAsync(cancellationToken); - } - - public ValueTask DisposeAsync() - { - return _transaction.DisposeAsync(); - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 16d8cd1075..4747abe26d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -91,6 +91,20 @@ public sealed class HasManyThroughAttribute : HasManyAttribute /// public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; + /// + /// Optional. Can be used to indicate a non-default name for the ID property back to the parent resource from the through type. + /// Defaults to the name of suffixed with "Id". + /// In the example described above, this would be "ArticleId". + /// + public string LeftIdPropertyName { get; set; } + + /// + /// Optional. Can be used to indicate a non-default name for the ID property to the related resource from the through type. + /// Defaults to the name of suffixed with "Id". + /// In the example described above, this would be "TagId". + /// + public string RightIdPropertyName { get; set; } + /// /// Creates a HasMany relationship through a many-to-many join relationship. /// @@ -108,11 +122,15 @@ public override object GetValue(object resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - IEnumerable throughResources = (IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty(); + var value = ThroughProperty.GetValue(resource); + if (value == null) + { + return null; + } - IEnumerable rightResources = throughResources + IEnumerable rightResources = ((IEnumerable) value) .Cast() - .Select(rightResource => RightProperty.GetValue(rightResource)); + .Select(joinEntity => RightProperty.GetValue(joinEntity)); return TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } @@ -121,12 +139,11 @@ public override object GetValue(object resource) /// Traverses through the provided resource and sets the value of the relationship on the other side of the through type. /// In the example described above, this would be the value of "Articles.ArticleTags.Tag". /// - public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) + public override void SetValue(object resource, object newValue) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); - base.SetValue(resource, newValue, resourceFactory); + base.SetValue(resource, newValue); if (newValue == null) { @@ -137,7 +154,8 @@ public override void SetValue(object resource, object newValue, IResourceFactory List throughResources = new List(); foreach (IIdentifiable identifiable in (IEnumerable)newValue) { - object throughResource = resourceFactory.CreateInstance(ThroughType); + var throughResource = TypeHelper.CreateInstance(ThroughType); + LeftProperty.SetValue(throughResource, resource); RightProperty.SetValue(throughResource, identifiable); throughResources.Add(throughResource); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index d0e739aef9..9fd6efa4ef 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -1,5 +1,4 @@ using System; -using JsonApiDotNetCore.Configuration; namespace JsonApiDotNetCore.Resources.Annotations { @@ -9,54 +8,9 @@ namespace JsonApiDotNetCore.Resources.Annotations [AttributeUsage(AttributeTargets.Property)] public sealed class HasOneAttribute : RelationshipAttribute { - private string _identifiablePropertyName; - - /// - /// The foreign key property name. Defaults to "{RelationshipName}Id". - /// - /// - /// Using an alternative foreign key: - /// - /// public class Article : Identifiable - /// { - /// [HasOne(PublicName = "author", IdentifiablePropertyName = nameof(AuthorKey)] - /// public Author Author { get; set; } - /// public int AuthorKey { get; set; } - /// } - /// - /// - public string IdentifiablePropertyName - { - get => _identifiablePropertyName ?? JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(Property.Name); - set => _identifiablePropertyName = value; - } - public HasOneAttribute() { Links = LinkTypes.NotConfigured; } - - /// - public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) - { - if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); - - // If we're deleting the relationship (setting it to null), we set the foreignKey to null. - // We could also set the actual property to null, but then we would first need to load the - // current relationship, which requires an extra query. - - var propertyName = newValue == null ? IdentifiablePropertyName : Property.Name; - var resourceType = resource.GetType(); - - var propertyInfo = resourceType.GetProperty(propertyName); - if (propertyInfo == null) - { - // we can't set the FK to null because there isn't any. - propertyInfo = resourceType.GetProperty(RelationshipPath); - } - - propertyInfo.SetValue(resource, newValue); - } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index 11dffec12d..eeab77e715 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -11,7 +12,25 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute { private LinkTypes _links; - public string InverseNavigation { get; set; } + /// + /// The property name of the EF Core inverse navigation, which may or may not be exposed as a json:api relationship. + /// + /// + /// Articles { get; set; } + /// } + /// ]]> + /// + internal PropertyInfo InverseNavigationProperty { get; set; } /// /// The internal navigation property path to the related resource. @@ -67,7 +86,7 @@ public LinkTypes Links public bool CanInclude { get; set; } = true; /// - /// Gets the value of the resource property this attributes was declared on. + /// Gets the value of the resource property this attribute was declared on. /// public virtual object GetValue(object resource) { @@ -77,12 +96,11 @@ public virtual object GetValue(object resource) } /// - /// Sets the value of the resource property this attributes was declared on. + /// Sets the value of the resource property this attribute was declared on. /// - public virtual void SetValue(object resource, object newValue, IResourceFactory resourceFactory) + public virtual void SetValue(object resource, object newValue) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); Property.SetValue(resource, newValue); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs index 82aae71710..cceb0994d1 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -1,31 +1,32 @@ namespace JsonApiDotNetCore.Resources { /// - /// Used to determine whether additional changes to a resource, not specified in a PATCH request, have been applied. + /// Used to determine whether additional changes to a resource (side effects), not specified in a POST or PATCH request, have been applied. /// public interface IResourceChangeTracker where TResource : class, IIdentifiable { /// - /// Sets the exposed resource attributes as stored in database, before applying changes. + /// Sets the exposed resource attributes as stored in database, before applying the PATCH operation. + /// For POST operations, this sets exposed resource attributes to their default value. /// void SetInitiallyStoredAttributeValues(TResource resource); /// - /// Sets the subset of exposed attributes from the PATCH request. + /// Sets the (subset of) exposed resource attributes from the POST or PATCH request. /// void SetRequestedAttributeValues(TResource resource); /// - /// Sets the exposed resource attributes as stored in database, after applying changes. + /// Sets the exposed resource attributes as stored in database, after applying the POST or PATCH operation. /// void SetFinallyStoredAttributeValues(TResource resource); /// - /// Validates if any exposed resource attributes that were not in the PATCH request have been changed. - /// And validates if the values from the PATCH request are stored without modification. + /// Validates if any exposed resource attributes that were not in the POST or PATCH request have been changed. + /// And validates if the values from the request are stored without modification. /// /// - /// true if the attribute values from the PATCH request were the only changes; false, otherwise. + /// true if the attribute values from the POST or PATCH request were the only changes; false, otherwise. /// bool HasImplicitChanges(); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 1ed2356ff7..38a25ad996 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -11,12 +11,13 @@ public interface IResourceFactory /// /// Creates a new resource object instance. /// - public object CreateInstance(Type resourceType); - + public IIdentifiable CreateInstance(Type resourceType); + /// /// Creates a new resource object instance. /// - public TResource CreateInstance(); + public TResource CreateInstance() + where TResource : IIdentifiable; /// /// Returns an expression tree that represents creating a new resource object instance. diff --git a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs index 03262e834d..5cdb36950d 100644 --- a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs @@ -9,13 +9,13 @@ namespace JsonApiDotNetCore.Resources public interface ITargetedFields { /// - /// List of attributes that are targeted by a request. + /// The set of attributes that are targeted by a request. /// - IList Attributes { get; set; } + ISet Attributes { get; set; } /// - /// List of relationships that are targeted by a request. + /// The set of relationships that are targeted by a request. /// - IList Relationships { get; set; } + ISet Relationships { get; set; } } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 63c0d6dd46..3dee7f52c2 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Resources /// /// Compares `IIdentifiable` instances with each other based on StringId. /// - internal sealed class IdentifiableComparer : IEqualityComparer + public sealed class IdentifiableComparer : IEqualityComparer { public static readonly IdentifiableComparer Instance = new IdentifiableComparer(); diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs new file mode 100644 index 0000000000..1b62fe81af --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Reflection; + +namespace JsonApiDotNetCore.Resources +{ + public static class IdentifiableExtensions + { + internal static object GetTypedId(this IIdentifiable identifiable) + { + if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); + + PropertyInfo property = identifiable.GetType().GetProperty(nameof(Identifiable.Id)); + + if (property == null) + { + throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not have an Id property."); + } + + return property.GetValue(identifiable); + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 9df7cd9dd4..701b677c48 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -10,18 +10,18 @@ namespace JsonApiDotNetCore.Resources public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { private readonly IJsonApiOptions _options; - private readonly IResourceContextProvider _contextProvider; + private readonly IResourceContextProvider _resourceContextProvider; private readonly ITargetedFields _targetedFields; private IDictionary _initiallyStoredAttributeValues; private IDictionary _requestedAttributeValues; private IDictionary _finallyStoredAttributeValues; - public ResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider contextProvider, + public ResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider resourceContextProvider, ITargetedFields targetedFields) { _options = options ?? throw new ArgumentNullException(nameof(options)); - _contextProvider = contextProvider ?? throw new ArgumentNullException(nameof(contextProvider)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); } @@ -30,7 +30,7 @@ public void SetInitiallyStoredAttributeValues(TResource resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - var resourceContext = _contextProvider.GetResourceContext(); + var resourceContext = _resourceContextProvider.GetResourceContext(); _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); } @@ -47,7 +47,7 @@ public void SetFinallyStoredAttributeValues(TResource resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - var resourceContext = _contextProvider.GetResourceContext(); + var resourceContext = _resourceContextProvider.GetResourceContext(); _finallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); } diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 5405fd21e9..568e964c3a 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -3,8 +3,6 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; -using JsonApiDotNetCore.Repositories; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources @@ -20,7 +18,7 @@ public ResourceFactory(IServiceProvider serviceProvider) } /// - public object CreateInstance(Type resourceType) + public IIdentifiable CreateInstance(Type resourceType) { if (resourceType == null) { @@ -29,22 +27,23 @@ public object CreateInstance(Type resourceType) return InnerCreateInstance(resourceType, _serviceProvider); } - + /// public TResource CreateInstance() + where TResource : IIdentifiable { return (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); } - private static object InnerCreateInstance(Type type, IServiceProvider serviceProvider) + private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider serviceProvider) { bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); try { return hasSingleConstructorWithoutParameters - ? Activator.CreateInstance(type) - : ActivatorUtilities.CreateInstance(serviceProvider, type); + ? (IIdentifiable)Activator.CreateInstance(type) + : (IIdentifiable)ActivatorUtilities.CreateInstance(serviceProvider, type); } catch (Exception exception) { @@ -75,10 +74,8 @@ public NewExpression CreateNewExpression(Type resourceType) object constructorArgument = ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); - var argumentExpression = EntityFrameworkCoreSupport.Version.Major >= 5 - // Workaround for https://github.com/dotnet/efcore/issues/20502 to not fail on injected DbContext in EF Core 5. - ? CreateTupleAccessExpressionForConstant(constructorArgument, constructorArgument.GetType()) - : Expression.Constant(constructorArgument); + var argumentExpression = + CreateTupleAccessExpressionForConstant(constructorArgument, constructorArgument.GetType()); constructorArguments.Add(argumentExpression); } @@ -106,7 +103,7 @@ private static Expression CreateTupleAccessExpressionForConstant(object value, T return Expression.Property(tupleCreateCall, "Item1"); } - private static bool HasSingleConstructorWithoutParameters(Type type) + internal static bool HasSingleConstructorWithoutParameters(Type type) { ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray(); @@ -121,7 +118,7 @@ private static ConstructorInfo GetLongestConstructor(Type type) { throw new InvalidOperationException($"No public constructor was found for '{type.FullName}'."); } - + ConstructorInfo bestMatch = constructors[0]; int maxParameterLength = constructors[0].GetParameters().Length; diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs index 6784b8b9c8..46cd2fed6a 100644 --- a/src/JsonApiDotNetCore/Resources/TargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -7,9 +7,9 @@ namespace JsonApiDotNetCore.Resources public sealed class TargetedFields : ITargetedFields { /// - public IList Attributes { get; set; } = new List(); + public ISet Attributes { get; set; } = new HashSet(); /// - public IList Relationships { get; set; } = new List(); + public ISet Relationships { get; set; } = new HashSet(); } } diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 61d6ec1408..423dc2cdeb 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -2,9 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Client.Internal; @@ -21,7 +19,7 @@ namespace JsonApiDotNetCore.Serialization public abstract class BaseDeserializer { protected IResourceContextProvider ResourceContextProvider { get; } - protected IResourceFactory ResourceFactory{ get; } + protected IResourceFactory ResourceFactory { get; } protected Document Document { get; set; } protected BaseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) @@ -50,16 +48,20 @@ protected object DeserializeBody(string body) var bodyJToken = LoadJToken(body); Document = bodyJToken.ToObject(); - if (Document.IsManyData) + if (Document != null) { - if (Document.ManyData.Count == 0) - return Array.Empty(); + if (Document.IsManyData) + { + return Document.ManyData.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); + } - return Document.ManyData.Select(ParseResourceObject).ToArray(); + if (Document.SingleData != null) + { + return ParseResourceObject(Document.SingleData); + } } - if (Document.SingleData == null) return null; - return ParseResourceObject(Document.SingleData); + return null; } /// @@ -80,6 +82,11 @@ protected IIdentifiable SetAttributes(IIdentifiable resource, IDictionaryThe parsed resource. private IIdentifiable ParseResourceObject(ResourceObject data) { - var resourceContext = ResourceContextProvider.GetResourceContext(data.Type); - if (resourceContext == null) - { - throw new InvalidRequestBodyException("Payload includes unknown resource type.", - $"The resource '{data.Type}' is not registered on the resource graph. " + - "If you are using Entity Framework Core, make sure the DbSet matches the expected resource name. " + - "If you have manually registered the resource, check that the call to Add correctly sets the public name.", null); - } + AssertHasType(data, null); - var resource = (IIdentifiable)ResourceFactory.CreateInstance(resourceContext.ResourceType); + var resourceContext = GetExistingResourceContext(data.Type); + var resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); resource = SetRelationships(resource, data.Relationships, resourceContext.Relationships); @@ -157,73 +167,66 @@ private IIdentifiable ParseResourceObject(ResourceObject data) return resource; } + private ResourceContext GetExistingResourceContext(string publicName) + { + var resourceContext = ResourceContextProvider.GetResourceContext(publicName); + if (resourceContext == null) + { + throw new JsonApiSerializationException("Request body includes unknown resource type.", + $"Resource of type '{publicName}' does not exist."); + } + + return resourceContext; + } + /// - /// Sets a HasOne relationship on a parsed resource. If present, also - /// populates the foreign key. + /// Sets a HasOne relationship on a parsed resource. /// private void SetHasOneRelationship(IIdentifiable resource, - PropertyInfo[] resourceProperties, - HasOneAttribute attr, + HasOneAttribute hasOneRelationship, RelationshipEntry relationshipData) { + if (relationshipData.ManyData != null) + { + throw new JsonApiSerializationException("Expected single data for to-one relationship.", + $"Expected single data for '{hasOneRelationship.PublicName}' relationship."); + } + var rio = (ResourceIdentifierObject)relationshipData.Data; var relatedId = rio?.Id; - var relationshipType = relationshipData.SingleData == null - ? attr.RightType - : ResourceContextProvider.GetResourceContext(relationshipData.SingleData.Type).ResourceType; + Type relationshipType = hasOneRelationship.RightType; + + if (relationshipData.SingleData != null) + { + AssertHasType(relationshipData.SingleData, hasOneRelationship); + AssertHasId(relationshipData.SingleData, hasOneRelationship); - // this does not make sense in the following case: if we're setting the dependent of a one-to-one relationship, IdentifiablePropertyName should be null. - var foreignKeyProperty = resourceProperties.FirstOrDefault(p => p.Name == attr.IdentifiablePropertyName); + var rightResourceContext = GetExistingResourceContext(relationshipData.SingleData.Type); + AssertRightTypeIsCompatible(rightResourceContext, hasOneRelationship); - if (foreignKeyProperty != null) - // there is a FK from the current resource pointing to the related object, - // i.e. we're populating the relationship from the dependent side. - SetForeignKey(resource, foreignKeyProperty, attr, relatedId, relationshipType); + relationshipType = rightResourceContext.ResourceType; + } - SetNavigation(resource, attr, relatedId, relationshipType); + SetPrincipalSideOfHasOneRelationship(resource, hasOneRelationship, relatedId, relationshipType); // depending on if this base parser is used client-side or server-side, // different additional processing per field needs to be executed. - AfterProcessField(resource, attr, relationshipData); - } - - /// - /// Sets the dependent side of a HasOne relationship, which means that a - /// foreign key also will be populated. - /// - private void SetForeignKey(IIdentifiable resource, PropertyInfo foreignKey, HasOneAttribute attr, string id, - Type relationshipType) - { - bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKey.PropertyType) != null - || foreignKey.PropertyType == typeof(string); - if (id == null && !foreignKeyPropertyIsNullableType) - { - // this happens when a non-optional relationship is deliberately set to null. - // For a server deserializer, it should be mapped to a BadRequest HTTP error code. - throw new FormatException($"Cannot set required relationship identifier '{attr.IdentifiablePropertyName}' to null because it is a non-nullable type."); - } - - var typedId = TypeHelper.ConvertStringIdToTypedId(relationshipType, id, ResourceFactory); - foreignKey.SetValue(resource, typedId); + AfterProcessField(resource, hasOneRelationship, relationshipData); } - /// - /// Sets the principal side of a HasOne relationship, which means no - /// foreign key is involved. - /// - private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string relatedId, + private void SetPrincipalSideOfHasOneRelationship(IIdentifiable resource, HasOneAttribute attr, string relatedId, Type relationshipType) { if (relatedId == null) { - attr.SetValue(resource, null, ResourceFactory); + attr.SetValue(resource, null); } else { - var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipType); + var relatedInstance = ResourceFactory.CreateInstance(relationshipType); relatedInstance.StringId = relatedId; - attr.SetValue(resource, relatedInstance, ResourceFactory); + attr.SetValue(resource, relatedInstance); } } @@ -232,25 +235,67 @@ private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string /// private void SetHasManyRelationship( IIdentifiable resource, - HasManyAttribute attr, + HasManyAttribute hasManyRelationship, RelationshipEntry relationshipData) { - if (relationshipData.Data != null) - { // if the relationship is set to null, no need to set the navigation property to null: this is the default value. - var relatedResources = relationshipData.ManyData.Select(rio => - { - var relationshipType = ResourceContextProvider.GetResourceContext(rio.Type).ResourceType; - var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipType); - relatedInstance.StringId = rio.Id; - - return relatedInstance; - }); - - var convertedCollection = TypeHelper.CopyToTypedCollection(relatedResources, attr.Property.PropertyType); - attr.SetValue(resource, convertedCollection, ResourceFactory); + if (relationshipData.ManyData == null) + { + throw new JsonApiSerializationException("Expected data[] for to-many relationship.", + $"Expected data[] for '{hasManyRelationship.PublicName}' relationship."); } - AfterProcessField(resource, attr, relationshipData); + var rightResources = relationshipData.ManyData + .Select(rio => CreateRightResourceForHasMany(hasManyRelationship, rio)) + .ToHashSet(IdentifiableComparer.Instance); + + var convertedCollection = TypeHelper.CopyToTypedCollection(rightResources, hasManyRelationship.Property.PropertyType); + hasManyRelationship.SetValue(resource, convertedCollection); + + AfterProcessField(resource, hasManyRelationship, relationshipData); + } + + private IIdentifiable CreateRightResourceForHasMany(HasManyAttribute hasManyRelationship, ResourceIdentifierObject rio) + { + AssertHasType(rio, hasManyRelationship); + AssertHasId(rio, hasManyRelationship); + + var rightResourceContext = GetExistingResourceContext(rio.Type); + AssertRightTypeIsCompatible(rightResourceContext, hasManyRelationship); + + var rightInstance = ResourceFactory.CreateInstance(rightResourceContext.ResourceType); + rightInstance.StringId = rio.Id; + + return rightInstance; + } + + private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) + { + if (resourceIdentifierObject.Type == null) + { + var details = relationship != null + ? $"Expected 'type' element in '{relationship.PublicName}' relationship." + : "Expected 'type' element in 'data' element."; + + throw new JsonApiSerializationException("Request body must include 'type' element.", details); + } + } + + private void AssertHasId(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) + { + if (resourceIdentifierObject.Id == null) + { + throw new JsonApiSerializationException("Request body must include 'id' element.", + $"Expected 'id' element in '{relationship.PublicName}' relationship."); + } + } + + private void AssertRightTypeIsCompatible(ResourceContext rightResourceContext, RelationshipAttribute relationship) + { + if (!relationship.RightType.IsAssignableFrom(rightResourceContext.ResourceType)) + { + throw new JsonApiSerializationException("Relationship contains incompatible resource type.", + $"Relationship '{relationship.PublicName}' contains incompatible resource type '{rightResourceContext.PublicName}'."); + } } private object ConvertAttrValue(object newValue, Type targetType) diff --git a/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs index ee54290c92..39137d2612 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs @@ -13,10 +13,12 @@ public interface ILinkBuilder /// Builds the links object that is included in the top-level of the document. /// TopLevelLinks GetTopLevelLinks(); + /// /// Builds the links object for resources in the primary data. /// ResourceLinks GetResourceLinks(string resourceName, string id); + /// /// Builds the links object that is included in the values of the . /// diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs index 0b81bf3553..38468d6f75 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs @@ -38,7 +38,8 @@ public IList Build() foreach (var resourceObject in _included) { if (resourceObject.Relationships != null) - { // removes relationship entries (s) if they're completely empty. + { + // removes relationship entries (s) if they're completely empty. var pruned = resourceObject.Relationships.Where(p => p.Value.IsPopulated || p.Value.Links != null).ToDictionary(p => p.Key, p => p.Value); if (!pruned.Any()) pruned = null; resourceObject.Relationships = pruned; @@ -104,7 +105,8 @@ private void ProcessRelationship(RelationshipAttribute originRelationship, IIden relationshipEntry.Data = GetRelatedResourceLinkage(nextRelationship, parent); if (relationshipEntry.HasResource) - { // if the relationship is set, continue parsing the chain. + { + // if the relationship is set, continue parsing the chain. var related = nextRelationship.GetValue(parent); ProcessChain(nextRelationship, related, chainRemainder); } diff --git a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs index 4e0cb1011f..9773b7630b 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs @@ -109,6 +109,11 @@ private string GetSelfTopLevelLink(ResourceContext resourceContext, Action private bool ShouldAddResourceLink(ResourceContext resourceContext, LinkTypes link) { + if (_request.Kind == EndpointKind.Relationship) + { + return false; + } + if (resourceContext.ResourceLinks != LinkTypes.NotConfigured) { return resourceContext.ResourceLinks.HasFlag(link); diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index 071fc73e30..fbf154ec51 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Configuration; @@ -77,11 +76,11 @@ protected object GetRelatedResourceLinkage(RelationshipAttribute relationship, I private ResourceIdentifierObject GetRelatedResourceLinkageForHasOne(HasOneAttribute relationship, IIdentifiable resource) { var relatedResource = (IIdentifiable)relationship.GetValue(resource); - if (relatedResource == null && IsRequiredToOneRelationship(relationship, resource)) - throw new NotSupportedException("Cannot serialize a required to one relationship that is not populated but was included in the set of relationships to be serialized."); if (relatedResource != null) + { return GetResourceIdentifier(relatedResource); + } return null; } @@ -91,11 +90,15 @@ private ResourceIdentifierObject GetRelatedResourceLinkageForHasOne(HasOneAttrib /// private List GetRelatedResourceLinkageForHasMany(HasManyAttribute relationship, IIdentifiable resource) { - var relatedResources = (IEnumerable)relationship.GetValue(resource); + var relatedResources = (IEnumerable)relationship.GetValue(resource); var manyData = new List(); if (relatedResources != null) - foreach (IIdentifiable relatedResource in relatedResources) + { + foreach (var relatedResource in relatedResources) + { manyData.Add(GetResourceIdentifier(relatedResource)); + } + } return manyData; } @@ -113,18 +116,6 @@ private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) }; } - /// - /// Checks if the to-one relationship is required by checking if the foreign key is nullable. - /// - private bool IsRequiredToOneRelationship(HasOneAttribute attr, IIdentifiable resource) - { - var foreignKey = resource.GetType().GetProperty(attr.IdentifiablePropertyName); - if (foreignKey != null && Nullable.GetUnderlyingType(foreignKey.PropertyType) == null) - return true; - - return false; - } - /// /// Puts the relationships of the resource into the resource object. /// diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs index eabca7e593..c088bdeeee 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs @@ -24,14 +24,14 @@ public interface IRequestSerializer string Serialize(IReadOnlyCollection resources); /// - /// Sets the attributes that will be included in the serialized payload. + /// Sets the attributes that will be included in the serialized request body. /// You can use /// to conveniently access the desired instances. /// public IReadOnlyCollection AttributesToSerialize { set; } /// - /// Sets the relationships that will be included in the serialized payload. + /// Sets the relationships that will be included in the serialized request body. /// You can use /// to conveniently access the desired instances. /// diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs index d216db5818..b01ab6f11a 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs @@ -36,7 +36,7 @@ public string Serialize(IIdentifiable resource) } _currentTargetedResource = resource.GetType(); - var document = Build(resource, GetAttributesToSerialize(resource), GetRelationshipsToSerialize(resource)); + var document = Build(resource, GetAttributesToSerialize(resource), RelationshipsToSerialize); _currentTargetedResource = null; return SerializeObject(document, _jsonSerializerSettings); @@ -58,9 +58,8 @@ public string Serialize(IReadOnlyCollection resources) { _currentTargetedResource = firstResource.GetType(); var attributes = GetAttributesToSerialize(firstResource); - var relationships = GetRelationshipsToSerialize(firstResource); - document = Build(resources, attributes, relationships); + document = Build(resources, attributes, RelationshipsToSerialize); _currentTargetedResource = null; } @@ -83,7 +82,7 @@ private IReadOnlyCollection GetAttributesToSerialize(IIdentifiabl var currentResourceType = resource.GetType(); if (_currentTargetedResource != currentResourceType) // We're dealing with a relationship that is being serialized, for which - // we never want to include any attributes in the payload. + // we never want to include any attributes in the request body. return new List(); if (AttributesToSerialize == null) @@ -91,22 +90,5 @@ private IReadOnlyCollection GetAttributesToSerialize(IIdentifiabl return AttributesToSerialize; } - - /// - /// By default, the client serializer does not include any relationships - /// for resources in the primary data unless explicitly included using - /// . - /// - private IReadOnlyCollection GetRelationshipsToSerialize(IIdentifiable resource) - { - var currentResourceType = resource.GetType(); - // only allow relationship attributes to be serialized if they were set using - // - // and the current resource is a primary entry. - if (RelationshipsToSerialize == null) - return _resourceGraph.GetRelationships(currentResourceType); - - return RelationshipsToSerialize; - } } } diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs index 90f1f35a8f..c271f65b95 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs @@ -72,13 +72,13 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA { // add attributes and relationships of a parsed HasOne relationship var rio = data.SingleData; - hasOneAttr.SetValue(resource, rio == null ? null : ParseIncludedRelationship(rio), ResourceFactory); + hasOneAttr.SetValue(resource, rio == null ? null : ParseIncludedRelationship(rio)); } else if (field is HasManyAttribute hasManyAttr) { // add attributes and relationships of a parsed HasMany relationship var items = data.ManyData.Select(rio => ParseIncludedRelationship(rio)); var values = TypeHelper.CopyToTypedCollection(items, hasManyAttr.Property.PropertyType); - hasManyAttr.SetValue(resource, values, ResourceFactory); + hasManyAttr.SetValue(resource, values); } } @@ -94,7 +94,7 @@ private IIdentifiable ParseIncludedRelationship(ResourceIdentifierObject related throw new InvalidOperationException($"Included type '{relatedResourceIdentifier.Type}' is not a registered json:api resource."); } - var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relatedResourceContext.ResourceType); + var relatedInstance = ResourceFactory.CreateInstance(relatedResourceContext.ResourceType); relatedInstance.StringId = relatedResourceIdentifier.Id; var includedResource = GetLinkedResource(relatedResourceIdentifier); diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs index f08cbab1e6..f9740904fc 100644 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -15,15 +16,18 @@ public class FieldsToSerialize : IFieldsToSerialize private readonly IResourceGraph _resourceGraph; private readonly IEnumerable _constraintProviders; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IJsonApiRequest _jsonApiRequest; public FieldsToSerialize( IResourceGraph resourceGraph, IEnumerable constraintProviders, - IResourceDefinitionAccessor resourceDefinitionAccessor) + IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiRequest jsonApiRequest) { _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); + _jsonApiRequest = jsonApiRequest ?? throw new ArgumentNullException(nameof(jsonApiRequest)); } /// @@ -31,6 +35,11 @@ public IReadOnlyCollection GetAttributes(Type resourceType, Relat { if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (_jsonApiRequest.Kind == EndpointKind.Relationship) + { + return Array.Empty(); + } + var sparseFieldSetAttributes = _constraintProviders .SelectMany(p => p.GetConstraints()) .Where(expressionInScope => relationship == null @@ -79,7 +88,9 @@ public IReadOnlyCollection GetRelationships(Type type) { if (type == null) throw new ArgumentNullException(nameof(type)); - return _resourceGraph.GetRelationships(type); + return _jsonApiRequest.Kind == EndpointKind.Relationship + ? Array.Empty() + : _resourceGraph.GetRelationships(type); } } } diff --git a/src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs deleted file mode 100644 index 31635d5e36..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - internal interface IResponseSerializer - { - /// - /// Sets the designated request relationship in the case of requests of - /// the form a /articles/1/relationships/author. - /// - RelationshipAttribute RequestRelationship { get; set; } - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index d84e2ff526..23de159cef 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -4,11 +4,13 @@ using System.IO; using System.Net.Http; using System.Linq; +using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; @@ -43,89 +45,147 @@ public async Task ReadAsync(InputFormatterContext context) if (context == null) throw new ArgumentNullException(nameof(context)); - var request = context.HttpContext.Request; - if (request.ContentLength == 0) - { - return await InputFormatterResult.SuccessAsync(null); - } - - string body = await GetRequestBody(context.HttpContext.Request.Body); + string body = await GetRequestBodyAsync(context.HttpContext.Request.Body); string url = context.HttpContext.Request.GetEncodedUrl(); _traceWriter.LogMessage(() => $"Received request at '{url}' with body: <<{body}>>"); - object model; - try + object model = null; + if (!string.IsNullOrWhiteSpace(body)) { - model = _deserializer.Deserialize(body); + try + { + model = _deserializer.Deserialize(body); + } + catch (JsonApiSerializationException exception) + { + throw new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, body, exception); + } + catch (Exception exception) + { + throw new InvalidRequestBodyException(null, null, body, exception); + } } - catch (InvalidRequestBodyException exception) + + if (RequiresRequestBody(context.HttpContext.Request.Method)) { - exception.SetRequestBody(body); - throw; + ValidateRequestBody(model, body, context.HttpContext.Request); } - catch (Exception exception) + + return await InputFormatterResult.SuccessAsync(model); + } + + private async Task GetRequestBodyAsync(Stream bodyStream) + { + using var reader = new StreamReader(bodyStream); + return await reader.ReadToEndAsync(); + } + + private bool RequiresRequestBody(string requestMethod) + { + if (requestMethod == HttpMethods.Post || requestMethod == HttpMethods.Patch) { - throw new InvalidRequestBodyException(null, null, body, exception); + return true; } - ValidatePatchRequestIncludesId(context, model, body); - - ValidateIncomingResourceType(context, model); - - return await InputFormatterResult.SuccessAsync(model); + return requestMethod == HttpMethods.Delete && _request.Kind == EndpointKind.Relationship; } - private void ValidateIncomingResourceType(InputFormatterContext context, object model) + private void ValidateRequestBody(object model, string body, HttpRequest httpRequest) { - if (context.HttpContext.IsJsonApiRequest() && IsPatchOrPostRequest(context.HttpContext.Request)) + if (model == null && string.IsNullOrWhiteSpace(body)) { - var endpointResourceType = GetEndpointResourceType(); - if (endpointResourceType == null) + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) { - return; - } - - var bodyResourceTypes = GetBodyResourceTypes(model); - foreach (var bodyResourceType in bodyResourceTypes) - { - if (!endpointResourceType.IsAssignableFrom(bodyResourceType)) - { - var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endpointResourceType); - var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); - - throw new ResourceTypeMismatchException(new HttpMethod(context.HttpContext.Request.Method), - context.HttpContext.Request.Path, - resourceFromEndpoint, resourceFromBody); - } - } + Title = "Missing request body." + }); + } + + ValidateIncomingResourceType(model, httpRequest); + + if (httpRequest.Method != HttpMethods.Post || _request.Kind == EndpointKind.Relationship) + { + ValidateRequestIncludesId(model, body); + ValidatePrimaryIdValue(model, httpRequest.Path); + } + + if (IsPatchRequestForToManyRelationship(httpRequest.Method) && model == null) + { + throw new InvalidRequestBodyException("Expected data[] for to-many relationship.", + $"Expected data[] for '{_request.Relationship.PublicName}' relationship.", body); } } - private void ValidatePatchRequestIncludesId(InputFormatterContext context, object model, string body) + private void ValidateIncomingResourceType(object model, HttpRequest httpRequest) { - if (context.HttpContext.Request.Method == HttpMethods.Patch) + var endpointResourceType = GetResourceTypeFromEndpoint(); + if (endpointResourceType == null) { - bool hasMissingId = model is IList list ? HasMissingId(list) : HasMissingId(model); - if (hasMissingId) + return; + } + + var bodyResourceTypes = GetResourceTypesFromRequestBody(model); + foreach (var bodyResourceType in bodyResourceTypes) + { + if (!endpointResourceType.IsAssignableFrom(bodyResourceType)) { - throw new InvalidRequestBodyException("Payload must include 'id' element.", null, body); + var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endpointResourceType); + var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); + + throw new ResourceTypeMismatchException(new HttpMethod(httpRequest.Method), + httpRequest.Path, resourceFromEndpoint, resourceFromBody); } + } + } + + private Type GetResourceTypeFromEndpoint() + { + return _request.Kind == EndpointKind.Primary + ? _request.PrimaryResource.ResourceType + : _request.SecondaryResource?.ResourceType; + } + + private IEnumerable GetResourceTypesFromRequestBody(object model) + { + if (model is IEnumerable resourceCollection) + { + return resourceCollection.Select(r => r.GetType()).Distinct(); + } + + return model == null ? Array.Empty() : new[] { model.GetType() }; + } + + private void ValidateRequestIncludesId(object model, string body) + { + bool hasMissingId = model is IEnumerable list ? HasMissingId(list) : HasMissingId(model); + if (hasMissingId) + { + throw new InvalidRequestBodyException("Request body must include 'id' element.", null, body); + } + } - if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) + private void ValidatePrimaryIdValue(object model, PathString requestPath) + { + if (_request.Kind == EndpointKind.Primary) + { + if (TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) { - throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); + throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, requestPath); } } } - /// Checks if the deserialized payload has an ID included + /// + /// Checks if the deserialized request body has an ID included. + /// private bool HasMissingId(object model) { return TryGetId(model, out string id) && string.IsNullOrEmpty(id); } - /// Checks if all elements in the deserialized payload have an ID included + /// + /// Checks if all elements in the deserialized request body have an ID included. + /// private bool HasMissingId(IEnumerable models) { foreach (var model in models) @@ -141,12 +201,6 @@ private bool HasMissingId(IEnumerable models) private static bool TryGetId(object model, out string id) { - if (model is ResourceObject resourceObject) - { - id = resourceObject.Id; - return true; - } - if (model is IIdentifiable identifiable) { id = identifiable.StringId; @@ -157,40 +211,10 @@ private static bool TryGetId(object model, out string id) return false; } - /// - /// Fetches the request from body asynchronously. - /// - /// Input stream for body - /// String content of body sent to server. - private async Task GetRequestBody(Stream body) + private bool IsPatchRequestForToManyRelationship(string requestMethod) { - using var reader = new StreamReader(body); - // This needs to be set to async because - // Synchronous IO operations are - // https://github.com/aspnet/AspNetCore/issues/7644 - return await reader.ReadToEndAsync(); - } - - private bool IsPatchOrPostRequest(HttpRequest request) - { - return request.Method == HttpMethods.Patch || request.Method == HttpMethods.Post; - } - - private IEnumerable GetBodyResourceTypes(object model) - { - if (model is IEnumerable resourceCollection) - { - return resourceCollection.Select(r => r.GetType()).Distinct(); - } - - return model == null ? new Type[0] : new[] { model.GetType() }; - } - - private Type GetEndpointResourceType() - { - return _request.Kind == EndpointKind.Primary - ? _request.PrimaryResource.ResourceType - : _request.SecondaryResource?.ResourceType; + return requestMethod == HttpMethods.Patch && _request.Kind == EndpointKind.Relationship && + _request.Relationship is HasManyAttribute; } } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs new file mode 100644 index 0000000000..482f911c92 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs @@ -0,0 +1,20 @@ +using System; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// The error that is thrown when (de)serialization of a json:api body fails. + /// + public class JsonApiSerializationException : Exception + { + public string GenericMessage { get; } + public string SpecificMessage { get; } + + public JsonApiSerializationException(string genericMessage, string specificMessage, Exception innerException = null) + : base(genericMessage, innerException) + { + GenericMessage = genericMessage; + SpecificMessage = specificMessage; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 6c4a26796e..e996cfe969 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,7 +1,7 @@ using System; using System.Net.Http; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -16,12 +16,19 @@ public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer { private readonly ITargetedFields _targetedFields; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IJsonApiRequest _request; - public RequestDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields, IHttpContextAccessor httpContextAccessor) + public RequestDeserializer( + IResourceContextProvider resourceContextProvider, + IResourceFactory resourceFactory, + ITargetedFields targetedFields, + IHttpContextAccessor httpContextAccessor, + IJsonApiRequest request) : base(resourceContextProvider, resourceFactory) { _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + _request = request ?? throw new ArgumentNullException(nameof(request)); } /// @@ -29,6 +36,11 @@ public object Deserialize(string body) { if (body == null) throw new ArgumentNullException(nameof(body)); + if (_request.Kind == EndpointKind.Relationship) + { + _targetedFields.Relationships.Add(_request.Relationship); + } + return DeserializeBody(body); } @@ -46,17 +58,17 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Post.Method && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) { - throw new InvalidRequestBodyException( - "Assigning to the requested attribute is not allowed.", - $"Assigning to '{attr.PublicName}' is not allowed.", null); + throw new JsonApiSerializationException( + "Setting the initial value of the requested attribute is not allowed.", + $"Setting the initial value of '{attr.PublicName}' is not allowed."); } if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) { - throw new InvalidRequestBodyException( + throw new JsonApiSerializationException( "Changing the value of the requested attribute is not allowed.", - $"Changing the value of '{attr.PublicName}' is not allowed.", null); + $"Changing the value of '{attr.PublicName}' is not allowed."); } _targetedFields.Attributes.Add(attr); diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index 2e5b0a4afc..c608b54093 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -22,11 +22,8 @@ namespace JsonApiDotNetCore.Serialization /// /// Type of the resource associated with the scope of the request /// for which this serializer is used. - public class ResponseSerializer : BaseSerializer, IJsonApiSerializer, IResponseSerializer - where TResource : class, IIdentifiable + public class ResponseSerializer : BaseSerializer, IJsonApiSerializer where TResource : class, IIdentifiable { - public RelationshipAttribute RequestRelationship { get; set; } - private readonly IFieldsToSerialize _fieldsToSerialize; private readonly IJsonApiOptions _options; private readonly IMetaBuilder _metaBuilder; @@ -84,17 +81,13 @@ private string SerializeErrorDocument(ErrorDocument errorDocument) /// internal string SerializeSingle(IIdentifiable resource) { - if (RequestRelationship != null && resource != null) - { - var relationship = ((ResponseResourceObjectBuilder)ResourceObjectBuilder).Build(resource, RequestRelationship); - return SerializeObject(relationship, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); - } - var (attributes, relationships) = GetFieldsToSerialize(); var document = Build(resource, attributes, relationships); var resourceObject = document.SingleData; if (resourceObject != null) + { resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); + } AddTopLevelObjects(document); @@ -120,7 +113,9 @@ internal string SerializeMany(IReadOnlyCollection resources) { var links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); if (links == null) + { break; + } resourceObject.Links = links; } diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs index 0232eec083..6f995d061d 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs @@ -21,17 +21,14 @@ public ResponseSerializerFactory(IJsonApiRequest request, IRequestScopedServiceP } /// - /// Initializes the server serializer using the - /// associated with the current request. + /// Initializes the server serializer using the associated with the current request. /// public IJsonApiSerializer GetSerializer() { var targetType = GetDocumentType(); var serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); - var serializer = (IResponseSerializer)_provider.GetRequiredService(serializerType); - if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null) - serializer.RequestRelationship = _request.Relationship; + var serializer = _provider.GetRequiredService(serializerType); return (IJsonApiSerializer)serializer; } diff --git a/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs new file mode 100644 index 0000000000..0a400e5512 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace JsonApiDotNetCore.Services +{ + public static class AsyncCollectionExtensions + { + public static async Task AddRangeAsync(this ICollection source, IAsyncEnumerable elementsToAdd) + { + await foreach (var missingResource in elementsToAdd) + { + source.Add(missingResource); + } + } + + public static async Task> ToListAsync(this IAsyncEnumerable source) + { + var list = new List(); + + await foreach (var element in source) + { + list.Add(element); + } + + return list; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs new file mode 100644 index 0000000000..528a612ef5 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Services +{ + // TODO: Reconsider responsibilities (IQueryLayerComposer?) + /// + // TODO: Refactor this type (it is a helper method). + public class GetResourcesByIds : IGetResourcesByIds + { + private readonly IResourceGraph _resourceGraph; + private readonly IResourceRepositoryAccessor _resourceRepositoryAccessor; + + public GetResourcesByIds(IResourceGraph resourceGraph, IResourceRepositoryAccessor resourceRepositoryAccessor) + { + _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + _resourceRepositoryAccessor = resourceRepositoryAccessor ?? throw new ArgumentNullException(nameof(resourceRepositoryAccessor)); + } + + /// + public async Task> Get(Type resourceType, ISet typedIds) + { + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (typedIds == null ) throw new ArgumentNullException(nameof(typedIds)); + + if (typedIds.Any()) + { + var resourceContext = _resourceGraph.GetResourceContext(resourceType); + + var primaryIdProjection = CreatePrimaryIdProjection(resourceContext); + + var idValues = typedIds.Select(id => id.ToString()).ToArray(); + var idsFilter = CreateFilterByIds(idValues, resourceContext); + + var queryLayer = new QueryLayer(resourceContext) + { + Projection = primaryIdProjection, + Filter = idsFilter + }; + + return await _resourceRepositoryAccessor.GetAsync(resourceType, queryLayer); + } + + return Array.Empty(); + } + + private Dictionary CreatePrimaryIdProjection(ResourceContext resourceContext) + { + var idAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + var primaryIdProjection = new Dictionary {{idAttribute, null}}; + return primaryIdProjection; + } + + private FilterExpression CreateFilterByIds(ICollection ids, ResourceContext resourceContext) + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var idChain = new ResourceFieldChainExpression(idAttribute); + + if (ids.Count == 1) + { + var constant = new LiteralConstantExpression(ids.Single()); + return new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + } + + var constants = ids.Select(id => new LiteralConstantExpression(id)).ToList(); + return new EqualsAnyOfExpression(idChain, constants); + } + } +} diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs new file mode 100644 index 0000000000..4d235cffcb --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IAddToRelationshipService : IAddToRelationshipService + where TResource : class, IIdentifiable { } + + /// + public interface IAddToRelationshipService where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to add resources to a to-many relationship. + /// + /// The identifier of the primary resource. + /// The relationship to add resources to. + /// The set of resources to add to the relationship. + Task AddToToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds); + } +} diff --git a/src/JsonApiDotNetCore/Services/ICreateService.cs b/src/JsonApiDotNetCore/Services/ICreateService.cs index d49a9324e4..9cbb6d18df 100644 --- a/src/JsonApiDotNetCore/Services/ICreateService.cs +++ b/src/JsonApiDotNetCore/Services/ICreateService.cs @@ -13,7 +13,7 @@ public interface ICreateService where TResource : class, IIdentifiable { /// - /// Handles a json:api request to create a new resource. + /// Handles a json:api request to create a new resource with attributes, relationships or both. /// Task CreateAsync(TResource resource); } diff --git a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs index 7cd926b3a0..444bba4ad5 100644 --- a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs @@ -15,6 +15,6 @@ public interface IGetRelationshipService /// /// Handles a json:api request to retrieve a single relationship. /// - Task GetRelationshipAsync(TId id, string relationshipName); + Task GetRelationshipAsync(TId id, string relationshipName); } } diff --git a/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs new file mode 100644 index 0000000000..dbb0ee4150 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + /// Gets resources by set of identifiers for a type that is known at runtime. + /// + public interface IGetResourcesByIds + { + /// + /// Retrieves resources of type where the identifiers match . + /// + /// The resource type to get. + /// The identifiers of the resources to get. + /// + Task> Get(Type resourceType, ISet typedIds); + } +} diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs new file mode 100644 index 0000000000..bbac022341 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IRemoveFromRelationshipService : IRemoveFromRelationshipService + where TResource : class, IIdentifiable { } + + /// + public interface IRemoveFromRelationshipService where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to remove resources from a to-many relationship. + /// + /// The identifier of the primary resource. + /// The relationship to remove resources from. + /// The set of resources to remove from the relationship. + Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds); + } +} diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index c756d3a87b..a769f90f4c 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -8,9 +8,11 @@ namespace JsonApiDotNetCore.Services /// The resource type. public interface IResourceCommandService : ICreateService, + IAddToRelationshipService, IUpdateService, - IUpdateRelationshipService, + ISetRelationshipService, IDeleteService, + IRemoveFromRelationshipService, IResourceCommandService where TResource : class, IIdentifiable { } @@ -22,9 +24,11 @@ public interface IResourceCommandService : /// The resource identifier type. public interface IResourceCommandService : ICreateService, + IAddToRelationshipService, IUpdateService, - IUpdateRelationshipService, - IDeleteService + ISetRelationshipService, + IDeleteService, + IRemoveFromRelationshipService where TResource : class, IIdentifiable { } } diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs new file mode 100644 index 0000000000..af34622f4b --- /dev/null +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface ISetRelationshipService : ISetRelationshipService + where TResource : class, IIdentifiable + { } + + /// + public interface ISetRelationshipService where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to perform a complete replacement of a relationship on an existing resource. + /// + /// The identifier of the primary resource. + /// The relationship for which to perform a complete replacement. + /// The resource or set of resources to assign to the relationship. + Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds); + } +} diff --git a/src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs b/src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs deleted file mode 100644 index 0b3b27fa9f..0000000000 --- a/src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Services -{ - /// - public interface IUpdateRelationshipService : IUpdateRelationshipService - where TResource : class, IIdentifiable - { } - - /// - public interface IUpdateRelationshipService - where TResource : class, IIdentifiable - { - /// - /// Handles a json:api request to update an existing relationship. - /// - Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships); - } -} diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index c34b8ed511..1e2b60832f 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -13,7 +13,8 @@ public interface IUpdateService where TResource : class, IIdentifiable { /// - /// Handles a json:api request to update an existing resource. + /// Handles a json:api request to 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. /// Task UpdateAsync(TId id, TResource resource); } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 15dc320c78..985e00384c 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -23,6 +23,7 @@ public class JsonApiResourceService : where TResource : class, IIdentifiable { private readonly IResourceRepository _repository; + private readonly IGetResourcesByIds _getResourcesByIds; private readonly IQueryLayerComposer _queryLayerComposer; private readonly IPaginationContext _paginationContext; private readonly IJsonApiOptions _options; @@ -30,10 +31,13 @@ public class JsonApiResourceService : private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; private readonly IResourceFactory _resourceFactory; - private readonly IResourceHookExecutor _hookExecutor; + private readonly ITargetedFields _targetedFields; + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IResourceHookExecutorFacade _hookExecutor; public JsonApiResourceService( IResourceRepository repository, + IGetResourcesByIds getResourcesByIds, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -41,11 +45,14 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) + ITargetedFields targetedFields, + IResourceContextProvider resourceContextProvider, + IResourceHookExecutorFacade hookExecutor) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _getResourcesByIds = getResourcesByIds ?? throw new ArgumentNullException(nameof(getResourcesByIds)); _queryLayerComposer = queryLayerComposer ?? throw new ArgumentNullException(nameof(queryLayerComposer)); _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -53,60 +60,9 @@ public JsonApiResourceService( _request = request ?? throw new ArgumentNullException(nameof(request)); _resourceChangeTracker = resourceChangeTracker ?? throw new ArgumentNullException(nameof(resourceChangeTracker)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); - _hookExecutor = hookExecutor; - } - - /// - public virtual async Task CreateAsync(TResource resource) - { - _traceWriter.LogMethodStart(new {resource}); - if (resource == null) throw new ArgumentNullException(nameof(resource)); - - if (_hookExecutor != null) - { - resource = _hookExecutor.BeforeCreate(AsList(resource), ResourcePipeline.Post).Single(); - } - - await _repository.CreateAsync(resource); - - resource = await GetPrimaryResourceById(resource.Id, true); - - if (_hookExecutor != null) - { - _hookExecutor.AfterCreate(AsList(resource), ResourcePipeline.Post); - resource = _hookExecutor.OnReturn(AsList(resource), ResourcePipeline.Post).Single(); - } - - return resource; - } - - /// - public virtual async Task DeleteAsync(TId id) - { - _traceWriter.LogMethodStart(new {id}); - - if (_hookExecutor != null) - { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); - } - - var succeeded = await _repository.DeleteAsync(id); - - if (_hookExecutor != null) - { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - _hookExecutor.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); - } - - if (!succeeded) - { - AssertPrimaryResourceExists(null); - } + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _hookExecutor = hookExecutor ?? throw new ArgumentNullException(nameof(hookExecutor)); } /// @@ -114,7 +70,7 @@ public virtual async Task> GetAsync() { _traceWriter.LogMethodStart(); - _hookExecutor?.BeforeRead(ResourcePipeline.Get); + _hookExecutor.BeforeReadMany(); if (_options.IncludeTotalResourceCount) { @@ -130,18 +86,13 @@ public virtual async Task> GetAsync() var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); var resources = await _repository.GetAsync(queryLayer); - if (_hookExecutor != null) - { - _hookExecutor.AfterRead(resources, ResourcePipeline.Get); - return _hookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); - } - if (queryLayer.Pagination?.PageSize != null && queryLayer.Pagination.PageSize.Value == resources.Count) { _paginationContext.IsPageFull = true; } - return resources; + _hookExecutor.AfterReadMany(resources); + return _hookExecutor.OnReturnMany(resources); } /// @@ -149,66 +100,63 @@ public virtual async Task GetAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - _hookExecutor?.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); + _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetSingle); - var primaryResource = await GetPrimaryResourceById(id, true); + var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.PreserveExisting); - if (_hookExecutor != null) - { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetSingle); - return _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetSingle).Single(); - } + _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle); + _hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle); return primaryResource; } - private async Task GetPrimaryResourceById(TId id, bool allowTopSparseFieldSet) + /// + public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { - var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); - primaryLayer.Sort = null; - primaryLayer.Pagination = null; - primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); + _traceWriter.LogMethodStart(new {id, relationshipName}); + AssertRelationshipExists(relationshipName); - if (!allowTopSparseFieldSet && primaryLayer.Projection != null) - { - // Discard any ?fields= or attribute exclusions from ResourceDefinition, because we need the full record. + _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); - while (primaryLayer.Projection.Any(p => p.Key is AttrAttribute)) - { - primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); - } + var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); + var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + + if (_request.IsCollection && _options.IncludeTotalResourceCount) + { + // TODO: Consider support for pagination links on secondary resource collection. This requires to call Count() on the inverse relationship (which may not exist). + // For /blogs/{id}/articles we need to execute Count(Articles.Where(article => article.Blog.Id == 1 && article.Blog.existingFilter))) to determine TotalResourceCount. + // This also means we need to invoke ResourceRepository
.CountAsync() from ResourceService. + // And we should call BlogResourceDefinition.OnApplyFilter to filter out soft-deleted blogs and translate from equals('IsDeleted','false') to equals('Blog.IsDeleted','false') } var primaryResources = await _repository.GetAsync(primaryLayer); - + var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - return primaryResource; - } + _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetRelationship); - private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) - { - var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + var secondaryResourceOrResources = _request.Relationship.GetValue(primaryResource); - FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + if (secondaryResourceOrResources is ICollection secondaryResources && + secondaryLayer.Pagination?.PageSize != null && + secondaryLayer.Pagination.PageSize.Value == secondaryResources.Count) + { + _paginationContext.IsPageFull = true; + } - return existingFilter == null - ? filterById - : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); + return _hookExecutor.OnReturnRelationship(secondaryResourceOrResources); } /// - // triggered by GET /articles/1/relationships/{relationshipName} - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); @@ -221,131 +169,328 @@ public virtual async Task GetRelationshipAsync(TId id, string relatio var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - if (_hookExecutor != null) + _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetRelationship); + + var secondaryResourceOrResources = _request.Relationship.GetValue(primaryResource); + + return _hookExecutor.OnReturnRelationship(secondaryResourceOrResources); + } + + /// + public virtual async Task CreateAsync(TResource resource) + { + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + var resourceFromRequest = resource; + _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + + var defaultResource = _resourceFactory.CreateInstance(); + defaultResource.Id = resource.Id; + + _resourceChangeTracker.SetInitiallyStoredAttributeValues(defaultResource); + + _hookExecutor.BeforeCreate(resourceFromRequest); + + try + { + await _repository.CreateAsync(resourceFromRequest); + } + catch (DataStoreUpdateException) { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); + var existingResource = await TryGetPrimaryResourceById(resource.Id, TopFieldSelection.OnlyIdAttribute); + if (existingResource != null) + { + throw new ResourceAlreadyExistsException(resource.StringId, _request.PrimaryResource.PublicName); + } + + await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resourceFromRequest); + throw; } - return primaryResource; + var resourceFromDatabase = await GetPrimaryResourceById(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); + + _hookExecutor.AfterCreate(resourceFromDatabase); + + _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); + + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + if (!hasImplicitChanges) + { + return null; + } + + _hookExecutor.OnReturnSingle(resourceFromDatabase, ResourcePipeline.Post); + return resourceFromDatabase; } /// - // triggered by GET /articles/1/{relationshipName} - public virtual async Task GetSecondaryAsync(TId id, string relationshipName) + public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName}); + _traceWriter.LogMethodStart(new { id, secondaryResourceIds }); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); AssertRelationshipExists(relationshipName); + AssertRelationshipIsToMany(); + + if (secondaryResourceIds.Any()) + { + try + { + await _repository.AddToToManyRelationshipAsync(id, secondaryResourceIds); + } + catch (DataStoreUpdateException) + { + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + throw; + } + } + } - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + /// + public virtual async Task UpdateAsync(TId id, TResource resource) + { + _traceWriter.LogMethodStart(new {id, resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (_request.IsCollection && _options.IncludeTotalResourceCount) + AssertResourceIdIsNotTargeted(); + + var resourceFromRequest = resource; + _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + + _hookExecutor.BeforeUpdateResource(resourceFromRequest); + + TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.OnlyAllAttributes); + + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); + + try { - // TODO: Consider support for pagination links on secondary resource collection. This requires to call Count() on the inverse relationship (which may not exist). - // For /blogs/1/articles we need to execute Count(Articles.Where(article => article.Blog.Id == 1 && article.Blog.existingFilter))) to determine TotalResourceCount. - // This also means we need to invoke ResourceRepository
.CountAsync() from ResourceService. - // And we should call BlogResourceDefinition.OnApplyFilter to filter out soft-deleted blogs and translate from equals('IsDeleted','false') to equals('Blog.IsDeleted','false') + await _repository.UpdateAsync(resourceFromRequest); + } + catch (DataStoreUpdateException) + { + await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resourceFromRequest); + throw; } - var primaryResources = await _repository.GetAsync(primaryLayer); - - var primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); + TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); - if (_hookExecutor != null) - { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); - } + _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); - var secondaryResource = _request.Relationship.GetValue(primaryResource); + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); - if (secondaryResource is ICollection secondaryResources && - secondaryLayer.Pagination?.PageSize != null && secondaryLayer.Pagination.PageSize.Value == secondaryResources.Count) + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + if (!hasImplicitChanges) { - _paginationContext.IsPageFull = true; + return null; } - return secondaryResource; + _hookExecutor.OnReturnSingle(afterResourceFromDatabase, ResourcePipeline.Patch); + return afterResourceFromDatabase; + } + + private void AssertResourceIdIsNotTargeted() + { + if (_targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) + { + throw new ResourceIdIsReadOnlyException(); + } } /// - public virtual async Task UpdateAsync(TId id, TResource requestResource) + public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, requestResource}); - if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - TResource databaseResource = await GetPrimaryResourceById(id, false); + AssertRelationshipExists(relationshipName); - _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); - _resourceChangeTracker.SetRequestedAttributeValues(requestResource); + await _hookExecutor.BeforeUpdateRelationshipAsync(id, + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); - if (_hookExecutor != null) + try { - requestResource = _hookExecutor.BeforeUpdate(AsList(requestResource), ResourcePipeline.Patch).Single(); + await _repository.SetRelationshipAsync(id, secondaryResourceIds); } + catch (DataStoreUpdateException) + { + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); + + throw; + } + + await _hookExecutor.AfterUpdateRelationshipAsync(id, + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); + } - await _repository.UpdateAsync(requestResource, databaseResource); + /// + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); - if (_hookExecutor != null) + await _hookExecutor.BeforeDeleteAsync(id, + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); + + try { - _hookExecutor.AfterUpdate(AsList(databaseResource), ResourcePipeline.Patch); - _hookExecutor.OnReturn(AsList(databaseResource), ResourcePipeline.Patch); + await _repository.DeleteAsync(id); + } + catch (DataStoreUpdateException) + { + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + throw; } - _repository.FlushFromCache(databaseResource); - TResource afterResource = await GetPrimaryResourceById(id, false); - _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResource); - - bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? afterResource : null; + await _hookExecutor.AfterDeleteAsync(id, + async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); } /// - // triggered by PATCH /articles/1/relationships/{relationshipName} - public virtual async Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships) + public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); AssertRelationshipExists(relationshipName); + AssertRelationshipIsToMany(); - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); - secondaryLayer.Include = null; + try + { + await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); + } + catch (DataStoreUpdateException) + { + await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); - primaryLayer.Projection = null; + await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); + throw; + } + } - var primaryResources = await _repository.GetAsync(primaryLayer); + private async Task GetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) + { + var primaryResource = await TryGetPrimaryResourceById(id, fieldSelection); - var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - if (_hookExecutor != null) + return primaryResource; + } + + private async Task TryGetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) + { + var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + primaryLayer.Sort = null; + primaryLayer.Pagination = null; + primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); + + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) + { + var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + primaryLayer.Projection = new Dictionary {{idAttribute, null}}; + } + else if (fieldSelection == TopFieldSelection.WithAllAttributes && primaryLayer.Projection != null) { - primaryResource = _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship).Single(); + // Discard any top-level ?fields= or attribute exclusions from resource definition, because we need the full database row. + while (primaryLayer.Projection.Any(p => p.Key is AttrAttribute)) + { + primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); + } } + else if (fieldSelection == TopFieldSelection.OnlyAllAttributes) + { + primaryLayer.Include = null; + primaryLayer.Projection = null; + } + + var primaryResources = await _repository.GetAsync(primaryLayer); + return primaryResources.SingleOrDefault(); + } - string[] relationshipIds = null; - if (relationships != null) + private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) + { + var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + + FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + + return existingFilter == null + ? filterById + : new LogicalExpression(LogicalOperator.And, new[] { filterById, existingFilter }); + } + + private async Task AssertRightResourcesInRelationshipsExistAsync(IEnumerable relationships, TResource leftResource) + { + var missingResources = new List(); + + foreach (var relationship in relationships) { - relationshipIds = _request.Relationship is HasOneAttribute - ? new[] {((IIdentifiable) relationships).StringId} - : ((IEnumerable) relationships).Select(e => e.StringId).ToArray(); + object rightValue = relationship.GetValue(leftResource); + ICollection rightResources = ExtractResources(rightValue); + + var missingResourcesInRelationship = GetMissingResourcesInRelationshipAsync(relationship, rightResources); + await missingResources.AddRangeAsync(missingResourcesInRelationship); } - await _repository.UpdateRelationshipAsync(primaryResource, _request.Relationship, relationshipIds ?? Array.Empty()); + if (missingResources.Any()) + { + throw new SecondaryResourcesNotFoundException(missingResources); + } + } - if (_hookExecutor != null && primaryResource != null) + private async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, object secondaryResourceIds) + { + ICollection rightResources = ExtractResources(secondaryResourceIds); + + var missingResources = await GetMissingResourcesInRelationshipAsync(relationship, rightResources).ToListAsync(); + if (missingResources.Any()) + { + throw new SecondaryResourcesNotFoundException(missingResources); + } + } + + private async IAsyncEnumerable GetMissingResourcesInRelationshipAsync( + RelationshipAttribute relationship, ICollection rightResources) + { + if (rightResources.Any()) + { + var rightIds = rightResources.Select(resource => resource.GetTypedId()).ToHashSet(); + var existingRightResources = await _getResourcesByIds.Get(relationship.RightType, rightIds); + + var existingResourceStringIds = existingRightResources.Select(resource => resource.StringId).ToArray(); + foreach (var rightResource in rightResources) + { + if (existingResourceStringIds.Contains(rightResource.StringId)) + { + continue; + } + + var resourceContext = _resourceContextProvider.GetResourceContext(rightResource.GetType()); + + yield return new MissingResourceInRelationship(relationship.PublicName, + resourceContext.PublicName, rightResource.StringId); + } + } + } + + private static ICollection ExtractResources(object value) + { + if (value is IEnumerable resources) { - _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); + return resources.ToList(); } + + if (value is IIdentifiable resource) + { + return new[] {resource}; + } + + return Array.Empty(); } private void AssertPrimaryResourceExists(TResource resource) @@ -358,16 +503,39 @@ private void AssertPrimaryResourceExists(TResource resource) private void AssertRelationshipExists(string relationshipName) { - var relationship = _request.Relationship; - if (relationship == null) + if (_request.Relationship == null) { throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); } } - private static List AsList(TResource resource) + private void AssertRelationshipIsToMany() + { + var relationship = _request.Relationship; + if (!(relationship is HasManyAttribute)) + { + throw new ToManyRelationshipRequiredException(relationship.PublicName); + } + } + + private enum TopFieldSelection { - return new List { resource }; + /// + /// Discards any included relationships and selects all resource attributes. + /// + OnlyAllAttributes, + /// + /// Preserves included relationships, but selects all resource attributes. + /// + WithAllAttributes, + /// + /// Discards any included relationships and selects only resource ID. + /// + OnlyIdAttribute, + /// + /// Preserves the existing selection of attributes and/or relationships. + /// + PreserveExisting } } @@ -381,6 +549,7 @@ public class JsonApiResourceService : JsonApiResourceService repository, + IGetResourcesByIds getResourcesByIds, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -388,9 +557,11 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) - : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) + ITargetedFields targetedFields, + IResourceContextProvider resourceContextProvider, + IResourceHookExecutorFacade hookExecutor) + : base(repository, getResourcesByIds, queryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) { } } } diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 2a0b802957..f9e028ed8b 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -286,15 +286,9 @@ public static object CreateInstance(Type type) public static object ConvertStringIdToTypedId(Type resourceType, string stringId, IResourceFactory resourceFactory) { - var tempResource = (IIdentifiable)resourceFactory.CreateInstance(resourceType); + var tempResource = resourceFactory.CreateInstance(resourceType); tempResource.StringId = stringId; - return GetResourceTypedId(tempResource); - } - - public static object GetResourceTypedId(IIdentifiable resource) - { - PropertyInfo property = resource.GetType().GetProperty(nameof(Identifiable.Id)); - return property.GetValue(resource); + return tempResource.GetTypedId(); } /// diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 5a7a67c000..44b2a4bf80 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -39,6 +39,9 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); } @@ -146,6 +149,7 @@ public class TestModelService : JsonApiResourceService { public TestModelService( IResourceRepository repository, + IGetResourcesByIds getResourcesByIds, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -153,9 +157,12 @@ public TestModelService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) - : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) + ITargetedFields targetedFields, + IResourceContextProvider resourceContextProvider, + IResourceHookExecutorFacade hookExecutor) + : base(repository, getResourcesByIds, queryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, + hookExecutor) { } } @@ -166,11 +173,11 @@ public TestModelRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, + IGetResourcesByIds getResourcesByIds, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, getResourcesByIds, loggerFactory) { } } diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index 09174695cd..5fac2caabe 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; @@ -51,11 +52,11 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri PublicName = "description", Property = typeof(TodoItem).GetProperty(nameof(TodoItem.Description)) }; - targetedFields.Setup(m => m.Attributes).Returns(new List { descAttr }); - targetedFields.Setup(m => m.Relationships).Returns(new List()); + targetedFields.Setup(m => m.Attributes).Returns(new HashSet { descAttr }); + targetedFields.Setup(m => m.Relationships).Returns(new HashSet()); // Act - await repository.UpdateAsync(todoItemUpdates, databaseResource); + await repository.UpdateAsync(todoItemUpdates); } // Assert - in different context @@ -88,8 +89,10 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri contextResolverMock.Setup(m => m.GetContext()).Returns(context); var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); var targetedFields = new Mock(); - var serviceFactory = new Mock().Object; - var repository = new EntityFrameworkCoreRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, serviceFactory, resourceFactory, new List(), NullLoggerFactory.Instance); + var getResourcesByIds = new Mock().Object; + var repository = new EntityFrameworkCoreRepository(targetedFields.Object, + contextResolverMock.Object, resourceGraph, resourceFactory, new List(), + getResourcesByIds, NullLoggerFactory.Instance); return (repository, targetedFields, resourceGraph); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index 4d6dc63aaa..ec519a898b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -107,7 +107,7 @@ public async Task Can_Get_Passports() } } - [Fact(Skip = "Requires fix for https://github.com/dotnet/efcore/issues/20502")] + [Fact] public async Task Can_Get_Passports_With_Filter() { // Arrange @@ -147,7 +147,7 @@ public async Task Can_Get_Passports_With_Filter() Assert.Equal("Joe", document.Included[0].Attributes["firstName"]); } - [Fact(Skip = "https://github.com/dotnet/efcore/issues/20502")] + [Fact] public async Task Can_Get_Passports_With_Sparse_Fieldset() { // Arrange diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs index ee8742e8d4..1d995bb721 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs @@ -131,12 +131,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = serializer.Serialize(model); // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index eda2dcdd4d..27d4affdb3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -1,37 +1,28 @@ -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Middleware; +using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance { - [Collection("WebHostCollection")] - public sealed class ManyToManyTests + // TODO: Move left-over tests in this file. + + public sealed class ManyToManyTests : IClassFixture> { - private readonly TestFixture _fixture; + private readonly IntegrationTestContext _testContext; private readonly Faker _authorFaker; private readonly Faker
_articleFaker; private readonly Faker _tagFaker; - public ManyToManyTests(TestFixture fixture) + public ManyToManyTests(IntegrationTestContext testContext) { - _fixture = fixture; - var context = _fixture.GetRequiredService(); + _testContext = testContext; _authorFaker = new Faker() .RuleFor(a => a.LastName, f => f.Random.Words(2)); @@ -46,460 +37,63 @@ public ManyToManyTests(TestFixture fixture) } [Fact] - public async Task Can_Fetch_Many_To_Many_Through_Id() - { - // Arrange - var context = _fixture.GetRequiredService(); - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag - { - Article = article, - Tag = tag - }; - context.ArticleTags.Add(articleTag); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}/tags"; - - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var response = await client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var document = JsonConvert.DeserializeObject(body); - Assert.Single(document.ManyData); - - var tagResponse = _fixture.GetDeserializer().DeserializeMany(body).Data.First(); - Assert.NotNull(tagResponse); - Assert.Equal(tag.Id, tagResponse.Id); - Assert.Equal(tag.Name, tagResponse.Name); - } - - [Fact] - public async Task Can_Fetch_Many_To_Many_Through_GetById_Relationship_Link() - { - // Arrange - var context = _fixture.GetRequiredService(); - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag - { - Article = article, - Tag = tag - }; - context.ArticleTags.Add(articleTag); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}/tags"; - - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var response = await client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var document = JsonConvert.DeserializeObject(body); - Assert.Null(document.Included); - - var tagResponse = _fixture.GetDeserializer().DeserializeMany(body).Data.First(); - Assert.NotNull(tagResponse); - Assert.Equal(tag.Id, tagResponse.Id); - } - - [Fact] - public async Task Can_Fetch_Many_To_Many_Through_Relationship_Link() - { - // Arrange - var context = _fixture.GetRequiredService(); - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag - { - Article = article, - Tag = tag - }; - context.ArticleTags.Add(articleTag); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}/relationships/tags"; - - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var response = await client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var document = JsonConvert.DeserializeObject(body); - Assert.Null(document.Included); - - var tagResponse = _fixture.GetDeserializer().DeserializeMany(body).Data.First(); - Assert.NotNull(tagResponse); - Assert.Equal(tag.Id, tagResponse.Id); - } - - [Fact] - public async Task Can_Fetch_Many_To_Many_Without_Include() - { - // Arrange - var context = _fixture.GetRequiredService(); - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag - { - Article = article, - Tag = tag - }; - context.ArticleTags.Add(articleTag); - await context.SaveChangesAsync(); - var route = $"/api/v1/articles/{article.Id}"; - - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var response = await client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var document = JsonConvert.DeserializeObject(body); - Assert.Null(document.SingleData.Relationships["tags"].ManyData); - } - - [Fact] - public async Task Can_Create_Many_To_Many() - { - // Arrange - var context = _fixture.GetRequiredService(); - var tag = _tagFaker.Generate(); - var author = _authorFaker.Generate(); - context.Tags.Add(tag); - context.AuthorDifferentDbContextName.Add(author); - await context.SaveChangesAsync(); - - var route = "/api/v1/articles"; - var request = new HttpRequestMessage(new HttpMethod("POST"), route); - var content = new - { - data = new - { - type = "articles", - attributes = new Dictionary - { - {"caption", "An article with relationships"} - }, - relationships = new Dictionary - { - { "author", new { - data = new - { - type = "authors", - id = author.StringId - } - } }, - { "tags", new { - data = new dynamic[] - { - new { - type = "tags", - id = tag.StringId - } - } - } } - } - } - }; - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Created == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.NotNull(articleResponse); - - var persistedArticle = await _fixture.Context.Articles - .Include(a => a.ArticleTags) - .SingleAsync(a => a.Id == articleResponse.Id); - - var persistedArticleTag = Assert.Single(persistedArticle.ArticleTags); - Assert.Equal(tag.Id, persistedArticleTag.TagId); - } - - [Fact] - public async Task Can_Update_Many_To_Many() - { - // Arrange - var context = _fixture.GetRequiredService(); - var tag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - context.Tags.Add(tag); - context.Articles.Add(article); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}"; - var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); - var content = new - { - data = new - { - type = "articles", - id = article.StringId, - relationships = new Dictionary - { - { "tags", new { - data = new [] { new - { - type = "tags", - id = tag.StringId - } } - } } - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.Null(articleResponse); - - _fixture.ReloadDbContext(); - var persistedArticle = await _fixture.Context.Articles - .Include(a => a.ArticleTags) - .SingleAsync(a => a.Id == article.Id); - - var persistedArticleTag = Assert.Single(persistedArticle.ArticleTags); - Assert.Equal(tag.Id, persistedArticleTag.TagId); - } - - [Fact] - public async Task Can_Update_Many_To_Many_With_Complete_Replacement() + public async Task Can_Get_HasManyThrough_Relationship_Through_Secondary_Endpoint() { // Arrange - var context = _fixture.GetRequiredService(); - var firstTag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = firstTag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - context.ArticleTags.Add(articleTag); - var secondTag = _tagFaker.Generate(); - context.Tags.Add(secondTag); - await context.SaveChangesAsync(); - var route = $"/api/v1/articles/{article.Id}"; - var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); - var content = new + await _testContext.RunOnDatabaseAsync(async dbContext => { - data = new - { - type = "articles", - id = article.StringId, - relationships = new Dictionary - { - { "tags", new { - data = new [] { new - { - type = "tags", - id = secondTag.StringId - } } - } } - } - } - }; + dbContext.ArticleTags.Add(existingArticleTag); + await dbContext.SaveChangesAsync(); + }); - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/tags"; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.Null(articleResponse); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - _fixture.ReloadDbContext(); - var persistedArticle = await _fixture.Context.Articles - .Include("ArticleTags.Tag") - .SingleOrDefaultAsync(a => a.Id == article.Id); - var tag = persistedArticle.ArticleTags.Select(at => at.Tag).Single(); - Assert.Equal(secondTag.Id, tag.Id); + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("tags"); + responseDocument.ManyData[0].Id.Should().Be(existingArticleTag.Tag.StringId); + responseDocument.ManyData[0].Attributes["name"].Should().Be(existingArticleTag.Tag.Name); } [Fact] - public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap() + public async Task Can_Get_HasManyThrough_Through_Relationship_Endpoint() { // Arrange - var context = _fixture.GetRequiredService(); - var firstTag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - var articleTag = new ArticleTag + var existingArticleTag = new ArticleTag { - Article = article, - Tag = firstTag + Article = _articleFaker.Generate(), + Tag = _tagFaker.Generate() }; - context.ArticleTags.Add(articleTag); - var secondTag = _tagFaker.Generate(); - context.Tags.Add(secondTag); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}"; - var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); - var content = new - { - data = new - { - type = "articles", - id = article.StringId, - relationships = new Dictionary - { - { "tags", new { - data = new [] { new - { - type = "tags", - id = firstTag.StringId - }, new - { - type = "tags", - id = secondTag.StringId - } } - } } - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.Null(articleResponse); - - _fixture.ReloadDbContext(); - var persistedArticle = await _fixture.Context.Articles - .Include(a => a.ArticleTags) - .SingleOrDefaultAsync(a => a.Id == article.Id); - var tags = persistedArticle.ArticleTags.Select(at => at.Tag).ToList(); - Assert.Equal(2, tags.Count); - } - - [Fact] - public async Task Can_Update_Many_To_Many_Through_Relationship_Link() - { - // Arrange - var context = _fixture.GetRequiredService(); - var tag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - context.Tags.Add(tag); - context.Articles.Add(article); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}/relationships/tags"; - var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); - var content = new + await _testContext.RunOnDatabaseAsync(async dbContext => { - data = new[] { - new { - type = "tags", - id = tag.StringId - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + dbContext.ArticleTags.Add(existingArticleTag); + await dbContext.SaveChangesAsync(); + }); - // @TODO - Use fixture - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); + var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - - _fixture.ReloadDbContext(); - var persistedArticle = await _fixture.Context.Articles - .Include(a => a.ArticleTags) - .SingleAsync(a => a.Id == article.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var persistedArticleTag = Assert.Single(persistedArticle.ArticleTags); - Assert.Equal(tag.Id, persistedArticleTag.TagId); + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("tags"); + responseDocument.ManyData[0].Id.Should().Be(existingArticleTag.Tag.StringId); + responseDocument.ManyData[0].Attributes.Should().BeNull(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index 6742ab6d80..d3929c6a6c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -141,7 +141,7 @@ public async Task Unauthorized_TodoItem() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -161,7 +161,7 @@ public async Task Unauthorized_Passport() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -186,7 +186,7 @@ public async Task Unauthorized_Article() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -213,7 +213,7 @@ public async Task Article_Is_Hidden() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); Assert.DoesNotContain(toBeExcluded, body); } @@ -254,7 +254,7 @@ public async Task Tag_Is_Hidden() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); Assert.DoesNotContain(toBeExcluded, body); } ///// @@ -284,7 +284,7 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() { { "passport", new { - data = new { type = "passports", id = $"{lockedPerson.Passport.StringId}" } + data = new { type = "passports", id = lockedPerson.Passport.StringId } } } } @@ -304,7 +304,7 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -335,7 +335,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() { { "passport", new { - data = new { type = "passports", id = $"{newPassport.StringId}" } + data = new { type = "passports", id = newPassport.StringId } } } } @@ -355,7 +355,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -406,7 +406,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion( // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -435,7 +435,7 @@ public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -464,10 +464,10 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() { { "stakeHolders", new { - data = new object[] + data = new[] { - new { type = "people", id = $"{persons[0].Id}" }, - new { type = "people", id = $"{persons[1].Id}" } + new { type = "people", id = persons[0].StringId }, + new { type = "people", id = persons[1].StringId } } } @@ -489,7 +489,7 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -521,10 +521,10 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() { { "stakeHolders", new { - data = new object[] + data = new[] { - new { type = "people", id = $"{persons[0].Id}" }, - new { type = "people", id = $"{persons[1].Id}" } + new { type = "people", id = persons[0].StringId }, + new { type = "people", id = persons[1].StringId } } } @@ -546,7 +546,7 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); @@ -575,7 +575,7 @@ public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + AssertEqualStatusCode(HttpStatusCode.Forbidden, response); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs deleted file mode 100644 index ae15051ab4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ /dev/null @@ -1,392 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public sealed class CreatingDataTests : FunctionalTestCollection - { - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public CreatingDataTests(StandardApplicationFactory factory) : base(factory) - { - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()); - } - - [Fact] - public async Task CreateResource_ModelWithEntityFrameworkInheritance_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { e.SecurityLevel, e.UserName, e.Password }); - var superUser = new SuperUser(_dbContext) { SecurityLevel = 1337, UserName = "Super", Password = "User" }; - - // Act - var (body, response) = await Post("/api/v1/superUsers", serializer.Serialize(superUser)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var createdSuperUser = _deserializer.DeserializeSingle(body).Data; - var first = _dbContext.Set().FirstOrDefault(e => e.Id.Equals(createdSuperUser.Id)); - Assert.NotNull(first); - } - - [Fact] - public async Task CreateResource_GuidResource_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.Owner }); - var owner = new Person(); - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - var todoItemCollection = new TodoItemCollection { Owner = owner }; - - // Act - var (_, response) = await Post("/api/v1/todoCollections", serializer.Serialize(todoItemCollection)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - } - - [Fact] - public async Task ClientGeneratedId_IntegerIdAndNotEnabled_IsForbidden() - { - // Arrange - var serializer = GetSerializer(e => new { e.Description, e.Ordinal, e.CreatedDate }); - var todoItem = _todoItemFaker.Generate(); - const int clientDefinedId = 9999; - todoItem.Id = clientDefinedId; - - // Act - var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("Specifying the resource ID in POST requests is not allowed.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task CreateWithRelationship_HasMany_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.TodoItems }); - var todoItem = _todoItemFaker.Generate(); - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - var todoCollection = new TodoItemCollection { TodoItems = new HashSet { todoItem } }; - - // Act - var (body, response) = await Post("/api/v1/todoCollections", serializer.Serialize(todoCollection)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var contextCollection = GetDbContext().TodoItemCollections.AsNoTracking() - .Include(c => c.Owner) - .Include(c => c.TodoItems) - .SingleOrDefault(c => c.Id == responseItem.Id); - - Assert.NotEmpty(contextCollection.TodoItems); - Assert.Equal(todoItem.Id, contextCollection.TodoItems.First().Id); - } - - [Fact] - public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncluded() - { - // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.TodoItems, e.Owner }); - var owner = new Person(); - var todoItem = new TodoItem { Owner = owner, Description = "Description" }; - _dbContext.People.Add(owner); - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - var todoCollection = new TodoItemCollection { Owner = owner, TodoItems = new HashSet { todoItem } }; - - // Act - var (body, response) = await Post("/api/v1/todoCollections?include=todoItems", serializer.Serialize(todoCollection)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.NotNull(responseItem); - Assert.NotEmpty(responseItem.TodoItems); - Assert.Equal(todoItem.Description, responseItem.TodoItems.Single().Description); - } - - [Fact] - public async Task CreateWithRelationship_HasManyAndIncludeAndSparseFieldset_IsCreatedAndIncluded() - { - // Arrange - var serializer = GetSerializer(e => new { e.Name }, e => new { e.TodoItems, e.Owner }); - var owner = new Person(); - var todoItem = new TodoItem { Owner = owner, Ordinal = 123, Description = "Description" }; - _dbContext.People.Add(owner); - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - var todoCollection = new TodoItemCollection {Owner = owner, Name = "Jack", TodoItems = new HashSet {todoItem}}; - - // Act - var (body, response) = await Post("/api/v1/todoCollections?include=todoItems&fields=name&fields[todoItems]=ordinal", serializer.Serialize(todoCollection)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.NotNull(responseItem); - Assert.Equal(todoCollection.Name, responseItem.Name); - - Assert.NotEmpty(responseItem.TodoItems); - Assert.Equal(todoItem.Ordinal, responseItem.TodoItems.Single().Ordinal); - Assert.Null(responseItem.TodoItems.Single().Description); - } - - [Fact] - public async Task CreateWithRelationship_HasOne_IsCreated() - { - // Arrange - var serializer = GetSerializer(attributes: ti => new { }, relationships: ti => new { ti.Owner }); - var todoItem = new TodoItem(); - var owner = new Person(); - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - todoItem.Owner = owner; - - // Act - var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var todoItemResult = GetDbContext().TodoItems.AsNoTracking() - .Include(c => c.Owner) - .SingleOrDefault(c => c.Id == responseItem.Id); - Assert.Equal(owner.Id, todoItemResult.OwnerId); - } - - [Fact] - public async Task CreateWithRelationship_HasOneAndInclude_IsCreatedAndIncluded() - { - // Arrange - var serializer = GetSerializer(attributes: ti => new { }, relationships: ti => new { ti.Owner }); - var todoItem = new TodoItem(); - var owner = new Person { FirstName = "Alice" }; - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - todoItem.Owner = owner; - - // Act - var (body, response) = await Post("/api/v1/todoItems?include=owner", serializer.Serialize(todoItem)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.NotNull(responseItem); - Assert.NotNull(responseItem.Owner); - Assert.Equal(owner.FirstName, responseItem.Owner.FirstName); - } - - [Fact] - public async Task CreateWithRelationship_HasOneAndIncludeAndSparseFieldset_IsCreatedAndIncluded() - { - // Arrange - var serializer = GetSerializer(attributes: ti => new { ti.Ordinal }, relationships: ti => new { ti.Owner }); - var todoItem = new TodoItem - { - Ordinal = 123, - Description = "some" - }; - var owner = new Person { FirstName = "Alice", LastName = "Cooper" }; - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - todoItem.Owner = owner; - - // Act - var (body, response) = await Post("/api/v1/todoItems?include=owner&fields=ordinal&fields[owner]=firstName", serializer.Serialize(todoItem)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - - Assert.NotNull(responseItem); - Assert.Equal(todoItem.Ordinal, responseItem.Ordinal); - Assert.Null(responseItem.Description); - - Assert.NotNull(responseItem.Owner); - Assert.Equal(owner.FirstName, responseItem.Owner.FirstName); - Assert.Null(responseItem.Owner.LastName); - } - - [Fact] - public async Task CreateWithRelationship_HasOneFromIndependentSide_IsCreated() - { - // Arrange - var serializer = GetSerializer(pr => new { }, pr => new { pr.Person }); - var person = new Person(); - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - var personRole = new PersonRole { Person = person }; - - // Act - var (body, response) = await Post("/api/v1/personRoles", serializer.Serialize(personRole)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var personRoleResult = _dbContext.PersonRoles.AsNoTracking() - .Include(c => c.Person) - .SingleOrDefault(c => c.Id == responseItem.Id); - Assert.NotEqual(0, responseItem.Id); - Assert.Equal(person.Id, personRoleResult.Person.Id); - } - - [Fact] - public async Task CreateResource_SimpleResource_HeaderLocationsAreCorrect() - { - // Arrange - var serializer = GetSerializer(ti => new { ti.CreatedDate, ti.Description, ti.Ordinal }); - var todoItem = _todoItemFaker.Generate(); - - // Act - var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); - var responseItem = _deserializer.DeserializeSingle(body).Data; - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - Assert.Equal($"/api/v1/todoItems/{responseItem.Id}", response.Headers.Location.ToString()); - } - - [Fact] - public async Task CreateResource_UnknownResourceType_Fails() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new - { - type = "something" - } - }); - - // Act - var (body, response) = await Post("/api/v1/todoItems", content); - - // Assert - AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); - Assert.Equal("Failed to deserialize request body: Payload includes unknown resource type.", errorDocument.Errors[0].Title); - Assert.StartsWith("The resource 'something' is not registered on the resource graph.", errorDocument.Errors[0].Detail); - Assert.Contains("Request body: <<", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task CreateResource_Blocked_Fails() - { - // Arrange - var content = new - { - data = new - { - type = "todoItems", - attributes = new Dictionary - { - { "alwaysChangingValue", "X" } - } - } - }; - - var requestBody = JsonConvert.SerializeObject(content); - - // Act - var (body, response) = await Post("/api/v1/todoItems", requestBody); - - // Assert - AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - - var error = errorDocument.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); - Assert.Equal("Failed to deserialize request body: Assigning to the requested attribute is not allowed.", error.Title); - Assert.StartsWith("Assigning to 'alwaysChangingValue' is not allowed. - Request body:", error.Detail); - } - - [Fact] - public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { e.FirstName }, e => new { e.Passport }); - var passport = new Passport(_dbContext); - var currentPerson = _personFaker.Generate(); - currentPerson.Passport = passport; - _dbContext.People.Add(currentPerson); - await _dbContext.SaveChangesAsync(); - var newPerson = _personFaker.Generate(); - newPerson.Passport = passport; - - // Act - var (body, response) = await Post("/api/v1/people", serializer.Serialize(newPerson)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var newPersonDb = _dbContext.People.AsNoTracking().Where(p => p.Id == responseItem.Id).Include(e => e.Passport).Single(); - Assert.NotNull(newPersonDb.Passport); - Assert.Equal(passport.Id, newPersonDb.Passport.Id); - } - - [Fact] - public async Task CreateRelationship_ToManyWithImplicitRemove_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { e.FirstName }, e => new { e.TodoItems }); - var currentPerson = _personFaker.Generate(); - var todoItems = _todoItemFaker.Generate(3); - currentPerson.TodoItems = todoItems.ToHashSet(); - _dbContext.Add(currentPerson); - await _dbContext.SaveChangesAsync(); - var firstTd = todoItems[0]; - var secondTd = todoItems[1]; - var thirdTd = todoItems[2]; - - var newPerson = _personFaker.Generate(); - newPerson.TodoItems = new HashSet { firstTd, secondTd }; - - // Act - var (body, response) = await Post("/api/v1/people", serializer.Serialize(newPerson)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - var newPersonDb = _dbContext.People.AsNoTracking().Where(p => p.Id == responseItem.Id).Include(e => e.TodoItems).Single(); - var oldPersonDb = _dbContext.People.AsNoTracking().Where(p => p.Id == currentPerson.Id).Include(e => e.TodoItems).Single(); - Assert.Equal(2, newPersonDb.TodoItems.Count); - Assert.Single(oldPersonDb.TodoItems); - Assert.NotNull(newPersonDb.TodoItems.SingleOrDefault(ti => ti.Id == firstTd.Id)); - Assert.NotNull(newPersonDb.TodoItems.SingleOrDefault(ti => ti.Id == secondTd.Id)); - Assert.NotNull(oldPersonDb.TodoItems.SingleOrDefault(ti => ti.Id == thirdTd.Id)); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs deleted file mode 100644 index 67ed04bdca..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public sealed class CreatingDataWithClientGeneratedIdsTests : FunctionalTestCollection - { - private readonly Faker _todoItemFaker; - - public CreatingDataWithClientGeneratedIdsTests(ClientGeneratedIdsApplicationFactory factory) : base(factory) - { - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - } - - [Fact] - public async Task ClientGeneratedId_IntegerIdAndEnabled_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { e.Description, e.Ordinal, e.CreatedDate }); - var todoItem = _todoItemFaker.Generate(); - const int clientDefinedId = 9999; - todoItem.Id = clientDefinedId; - - // Act - var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(clientDefinedId, responseItem.Id); - } - - [Fact] - public async Task ClientGeneratedId_GuidIdAndEnabled_IsCreated() - { - // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.Owner }); - var owner = new Person(); - _dbContext.People.Add(owner); - await _dbContext.SaveChangesAsync(); - var clientDefinedId = Guid.NewGuid(); - var todoItemCollection = new TodoItemCollection { Owner = owner, OwnerId = owner.Id, Id = clientDefinedId }; - - // Act - var (body, response) = await Post("/api/v1/todoCollections", serializer.Serialize(todoItemCollection)); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(clientDefinedId, responseItem.Id); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs deleted file mode 100644 index 3b74dd7502..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class DeletingDataTests - { - private readonly AppDbContext _context; - - public DeletingDataTests(TestFixture fixture) - { - _context = fixture.GetRequiredService(); - } - - [Fact] - public async Task Respond_404_If_ResourceDoesNotExist() - { - // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var httpMethod = new HttpMethod("DELETE"); - var route = "/api/v1/todoItems/123"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '123' does not exist.",errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs index 82fdfe32d7..42492f3abd 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs @@ -19,7 +19,7 @@ public DisableQueryAttributeTests(TestFixture fixture) } [Fact] - public async Task Cannot_Sort_If_Blocked_By_Controller() + public async Task Cannot_Sort_If_Query_String_Parameter_Is_Blocked_By_Controller() { // Arrange var httpMethod = new HttpMethod("GET"); @@ -42,7 +42,7 @@ public async Task Cannot_Sort_If_Blocked_By_Controller() } [Fact] - public async Task Cannot_Use_Custom_Query_Parameter_If_Blocked_By_Controller() + public async Task Cannot_Use_Custom_Query_String_Parameter_If_Blocked_By_Controller() { // Arrange var httpMethod = new HttpMethod("GET"); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index 3c65dec700..a5f4012f56 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -62,8 +62,7 @@ public async Task When_getting_existing_ToOne_relationship_it_should_succeed() string expected = @"{ ""links"": { - ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"", - ""related"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" + ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"" }, ""data"": { ""type"": ""people"", @@ -116,7 +115,7 @@ public async Task When_getting_existing_ToMany_relationship_it_should_succeed() var expected = @"{ ""links"": { ""self"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"", - ""related"": ""http://localhost/api/v1/authors/" + author.StringId + @"/articles"" + ""first"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"" }, ""data"": [ { @@ -335,7 +334,7 @@ public async Task When_getting_unknown_related_resource_it_should_fail() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); } [Fact] @@ -365,7 +364,7 @@ public async Task When_getting_unknown_relationship_for_resource_it_should_fail( Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs index 7e4a14b094..1030ff426c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs @@ -87,9 +87,8 @@ protected IResponseDeserializer GetDeserializer() protected void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) { - var content = response.Content.ReadAsStringAsync(); - content.Wait(); - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {content.Result}"); + var responseBody = response.Content.ReadAsStringAsync().Result; + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); } protected void ClearDbContext() diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs deleted file mode 100644 index 141c4ab96c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Net; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public sealed class ResourceTypeMismatchTests : FunctionalTestCollection - { - public ResourceTypeMismatchTests(StandardApplicationFactory factory) : base(factory) { } - - [Fact] - public async Task Posting_Resource_With_Mismatching_Resource_Type_Returns_Conflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new - { - type = "people" - } - }); - - // Act - var (body, _) = await Post("/api/v1/todoItems", content); - - // Assert - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'todoItems' in POST request body at endpoint '/api/v1/todoItems', instead of 'people'.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Patching_Resource_With_Mismatching_Resource_Type_Returns_Conflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new - { - type = "people", - id = 1 - } - }); - - // Act - var (body, _) = await Patch("/api/v1/todoItems/1", content); - - // Assert - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'todoItems' in PATCH request body at endpoint '/api/v1/todoItems/1', instead of 'people'.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Patching_Through_Relationship_Link_With_Mismatching_Resource_Type_Returns_Conflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new - { - type = "todoItems", - id = 1 - } - }); - - // Act - var (body, _) = await Patch("/api/v1/todoItems/1/relationships/owner", content); - - // Assert - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'people' in PATCH request body at endpoint '/api/v1/todoItems/1/relationships/owner', instead of 'todoItems'.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Patching_Through_Relationship_Link_With_Multiple_Resources_Types_Returns_Conflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new object[] - { - new { type = "todoItems", id = 1 }, - new { type = "articles", id = 2 }, - } - }); - - // Act - var (body, _) = await Patch("/api/v1/todoItems/1/relationships/childrenTodos", content); - - // Assert - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'todoItems' in PATCH request body at endpoint '/api/v1/todoItems/1/relationships/childrenTodos', instead of 'articles'.", errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs deleted file mode 100644 index 163e82d4ea..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ /dev/null @@ -1,485 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using FluentAssertions; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Authentication; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public sealed class UpdatingDataTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - - private readonly Faker _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - private readonly Faker _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - - public UpdatingDataTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - FakeLoggerFactory loggerFactory = null; - - testContext.ConfigureLogging(options => - { - loggerFactory = new FakeLoggerFactory(); - - options.ClearProviders(); - options.AddProvider(loggerFactory); - options.SetMinimumLevel(LogLevel.Trace); - options.AddFilter((category, level) => level == LogLevel.Trace && - (category == typeof(JsonApiReader).FullName || category == typeof(JsonApiWriter).FullName)); - }); - - testContext.ConfigureServicesBeforeStartup(services => - { - if (loggerFactory != null) - { - services.AddSingleton(_ => loggerFactory); - } - }); - } - - [Fact] - public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() - { - // Arrange - var clock = _testContext.Factory.Services.GetRequiredService(); - - SuperUser superUser = null; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - superUser = new SuperUser(dbContext) - { - SecurityLevel = 1337, - UserName = "joe@account.com", - Password = "12345", - LastPasswordChange = clock.UtcNow.LocalDateTime.AddMinutes(-15) - }; - - dbContext.Users.Add(superUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "superUsers", - id = superUser.StringId, - attributes = new Dictionary - { - ["securityLevel"] = 2674, - ["userName"] = "joe@other-domain.com", - ["password"] = "secret" - } - } - }; - - var route = "/api/v1/superUsers/" + superUser.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["securityLevel"].Should().Be(2674); - responseDocument.SingleData.Attributes["userName"].Should().Be("joe@other-domain.com"); - responseDocument.SingleData.Attributes.Should().NotContainKey("password"); - } - - [Fact] - public async Task Response422IfUpdatingNotSettableAttribute() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); - - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["calculatedValue"] = "calculated" - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); - responseDocument.Errors[0].Detail.Should().StartWith("Property 'TodoItem.CalculatedValue' is read-only. - Request body: <<"); - - loggerFactory.Logger.Messages.Should().HaveCount(2); - loggerFactory.Logger.Messages.Should().Contain(x => - x.Text.StartsWith("Received request at ") && x.Text.Contains("with body:")); - loggerFactory.Logger.Messages.Should().Contain(x => - x.Text.StartsWith("Sending 422 response for request at ") && - x.Text.Contains("Failed to deserialize request body.")); - } - - [Fact] - public async Task Respond_404_If_ResourceDoesNotExist() - { - // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = 99999999, - attributes = new Dictionary - { - ["description"] = "something else" - } - } - }; - - var route = "/api/v1/todoItems/" + 99999999; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); - responseDocument.Errors[0].Detail.Should().Be("Resource of type 'todoItems' with ID '99999999' does not exist."); - } - - [Fact] - public async Task Respond_422_If_IdNotInAttributeList() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - attributes = new Dictionary - { - ["description"] = "something else" - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Payload must include 'id' element."); - responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); - } - - [Fact] - public async Task Respond_409_If_IdInUrlIsDifferentFromIdInRequestBody() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - int differentTodoItemId = todoItem.Id + 1; - - var requestBody = new - { - data = new - { - type = "todoItems", - id = differentTodoItemId, - attributes = new Dictionary - { - ["description"] = "something else" - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); - responseDocument.Errors[0].Title.Should().Be("Resource ID mismatch between request body and endpoint URL."); - responseDocument.Errors[0].Detail.Should().Be($"Expected resource ID '{todoItem.Id}' in PATCH request body at endpoint 'http://localhost/api/v1/todoItems/{todoItem.Id}', instead of '{differentTodoItemId}'."); - } - - [Fact] - public async Task Respond_422_If_Broken_JSON_Payload() - { - // Arrange - var requestBody = "{ \"data\" {"; - - var route = "/api/v1/todoItems/"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); - responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); - } - - [Fact] - public async Task Respond_422_If_Blocked_For_Update() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["offsetDate"] = "2000-01-01" - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); - responseDocument.Errors[0].Detail.Should().StartWith("Changing the value of 'offsetDate' is not allowed. - Request body:"); - } - - [Fact] - public async Task Can_Patch_Resource() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["description"] = "something else", - ["ordinal"] = 1 - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["description"].Should().Be("something else"); - responseDocument.SingleData.Attributes["ordinal"].Should().Be(1); - responseDocument.SingleData.Relationships.Should().ContainKey("owner"); - responseDocument.SingleData.Relationships["owner"].SingleData.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var updated = await dbContext.TodoItems - .Include(t => t.Owner) - .SingleAsync(t => t.Id == todoItem.Id); - - updated.Description.Should().Be("something else"); - updated.Ordinal.Should().Be(1); - updated.Owner.Id.Should().Be(todoItem.Owner.Id); - }); - } - - [Fact] - public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "people", - id = todoItem.Owner.StringId, - attributes = new Dictionary - { - ["firstName"] = "John", - ["lastName"] = "Doe" - } - } - }; - - var route = "/api/v1/people/" + todoItem.Owner.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["firstName"].Should().Be("John"); - responseDocument.SingleData.Attributes["lastName"].Should().Be("Doe"); - responseDocument.SingleData.Relationships.Should().ContainKey("todoItems"); - responseDocument.SingleData.Relationships["todoItems"].Data.Should().BeNull(); - } - - [Fact] - public async Task Can_Patch_Resource_And_HasOne_Relationships() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - dbContext.People.Add(person); - - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - attributes = new Dictionary - { - ["description"] = "Something else", - }, - relationships = new Dictionary - { - ["owner"] = new - { - data = new - { - type = "people", - id = person.StringId - } - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var updated = await dbContext.TodoItems - .Include(t => t.Owner) - .SingleAsync(t => t.Id == todoItem.Id); - - updated.Description.Should().Be("Something else"); - updated.Owner.Id.Should().Be(person.Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 5764b77542..34dbf2c814 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -1,747 +1,191 @@ using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Middleware; +using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { - [Collection("WebHostCollection")] - public sealed class UpdatingRelationshipsTests + // TODO: Move left-over tests in this file. + + public sealed class UpdatingRelationshipsTests : IClassFixture> { - private readonly TestFixture _fixture; - private AppDbContext _context; - private readonly Faker _personFaker; - private readonly Faker _todoItemFaker; + private readonly IntegrationTestContext _testContext; - public UpdatingRelationshipsTests(TestFixture fixture) - { - _fixture = fixture; - _context = fixture.GetRequiredService(); - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()); + private readonly Faker _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()) + .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - } + private readonly Faker _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); - [Fact] - public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() + public UpdatingRelationshipsTests(IntegrationTestContext testContext) { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var strayTodoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - _context.TodoItems.Add(strayTodoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var content = new - { - data = new - { - type = "todoItems", - id = todoItem.Id, - relationships = new Dictionary - { - { "childrenTodos", new - { - data = new object[] - { - new { type = "todoItems", id = $"{todoItem.Id}" }, - new { type = "todoItems", id = $"{strayTodoItem.Id}" } - } - - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - - // Act - await client.SendAsync(request); - _context = _fixture.GetRequiredService(); - - var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.ChildrenTodos).First(); - - Assert.Contains(updatedTodoItem.ChildrenTodos, ti => ti.Id == todoItem.Id); + _testContext = testContext; } [Fact] - public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() + public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() { - // Arrange + // Arrange var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); + var otherTodoItem = _todoItemFaker.Generate(); - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var content = new + await _testContext.RunOnDatabaseAsync(async dbContext => { - data = new - { - type = "todoItems", - id = todoItem.Id, - relationships = new Dictionary - { - { "dependentOnTodo", new - { - data = new { type = "todoItems", id = $"{todoItem.Id}" } - } - } - } - } - }; + dbContext.TodoItems.AddRange(todoItem, otherTodoItem); + await dbContext.SaveChangesAsync(); + }); - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - - // Act - await client.SendAsync(request); - _context = _fixture.GetRequiredService(); - - var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.DependentOnTodo).First(); - - Assert.Equal(todoItem.Id, updatedTodoItem.DependentOnTodoId); - } - - [Fact] - public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patching_Resource() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var strayTodoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - _context.TodoItems.Add(strayTodoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var content = new + var requestBody = new { data = new { type = "todoItems", - id = todoItem.Id, + id = todoItem.StringId, relationships = new Dictionary { - { "dependentOnTodo", new - { - data = new { type = "todoItems", id = $"{todoItem.Id}" } - } - }, - { "childrenTodos", new + ["childrenTodos"] = new + { + data = new[] { - data = new object[] + new { - new { type = "todoItems", id = $"{todoItem.Id}" }, - new { type = "todoItems", id = $"{strayTodoItem.Id}" } - } - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - - // Act - await client.SendAsync(request); - _context = _fixture.GetRequiredService(); - - var updatedTodoItem = _context.TodoItems.AsNoTracking() - .Where(ti => ti.Id == todoItem.Id) - .Include(ti => ti.ParentTodo).First(); - - Assert.Equal(todoItem.Id, updatedTodoItem.ParentTodoId); - } - - [Fact] - public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() - { - // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoCollection.Owner = person; - todoCollection.TodoItems.Add(todoItem); - _context.TodoItemCollections.Add(todoCollection); - await _context.SaveChangesAsync(); - - var newTodoItem1 = _todoItemFaker.Generate(); - var newTodoItem2 = _todoItemFaker.Generate(); - _context.AddRange(newTodoItem1, newTodoItem2); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new - { - data = new - { - type = "todoCollections", - id = todoCollection.Id, - relationships = new Dictionary - { - { "todoItems", new - { - data = new object[] + type = "todoItems", + id = todoItem.StringId + }, + new { - new { type = "todoItems", id = $"{newTodoItem1.Id}" }, - new { type = "todoItems", id = $"{newTodoItem2.Id}" } + type = "todoItems", + id = otherTodoItem.StringId } - } } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoCollections/{todoCollection.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act - var response = await client.SendAsync(request); - _context = _fixture.GetRequiredService(); - var updatedTodoItems = _context.TodoItemCollections.AsNoTracking() - .Where(tic => tic.Id == todoCollection.Id) - .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; - - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // we are expecting two, not three, because the request does - // a "complete replace". - Assert.Equal(2, updatedTodoItems.Count); - } + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - [Fact] - public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targets_Already_Attached() - { - // It is possible that resources we're creating relationships to - // have already been included in dbContext the application beyond control - // of JANDC. For example: a user may have been loaded when checking permissions - // in business logic in controllers. In this case, - // this user may not be reattached to the db context in the repository. - - // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoCollection.Owner = person; - todoCollection.Name = "PRE-ATTACH-TEST"; - todoCollection.TodoItems.Add(todoItem); - _context.TodoItemCollections.Add(todoCollection); - await _context.SaveChangesAsync(); - - var newTodoItem1 = _todoItemFaker.Generate(); - var newTodoItem2 = _todoItemFaker.Generate(); - _context.AddRange(newTodoItem1, newTodoItem2); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var content = new + await _testContext.RunOnDatabaseAsync(async dbContext => { - data = new - { - type = "todoCollections", - id = todoCollection.Id, - attributes = new - { - name = todoCollection.Name - }, - relationships = new Dictionary - { - { "todoItems", new - { - data = new object[] - { - new { type = "todoItems", id = $"{newTodoItem1.Id}" }, - new { type = "todoItems", id = $"{newTodoItem2.Id}" } - } - - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoCollections/{todoCollection.Id}"; - var request = new HttpRequestMessage(httpMethod, route); + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.ChildrenTodos) + .FirstAsync(item => item.Id == todoItem.Id); - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - _context = _fixture.GetRequiredService(); - var updatedTodoItems = _context.TodoItemCollections.AsNoTracking() - .Where(tic => tic.Id == todoCollection.Id) - .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; - - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // we are expecting two, not three, because the request does - // a "complete replace". - Assert.Equal(2, updatedTodoItems.Count); + todoItemInDatabase.ChildrenTodos.Should().HaveCount(2); + todoItemInDatabase.ChildrenTodos.Should().ContainSingle(x => x.Id == todoItem.Id); + todoItemInDatabase.ChildrenTodos.Should().ContainSingle(x => x.Id == otherTodoItem.Id); + }); } [Fact] - public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overlap() - { - // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; - var person = _personFaker.Generate(); - var todoItem1 = _todoItemFaker.Generate(); - var todoItem2 = _todoItemFaker.Generate(); - todoCollection.Owner = person; - todoCollection.TodoItems.Add(todoItem1); - todoCollection.TodoItems.Add(todoItem2); - _context.TodoItemCollections.Add(todoCollection); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new - { - data = new - { - type = "todoCollections", - id = todoCollection.Id, - relationships = new Dictionary - { - { "todoItems", new - { - data = new object[] - { - new { type = "todoItems", id = $"{todoItem1.Id}" }, - new { type = "todoItems", id = $"{todoItem2.Id}" } - } - - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoCollections/{todoCollection.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - - _context = _fixture.GetRequiredService(); - var updatedTodoItems = _context.TodoItemCollections.AsNoTracking() - .Where(tic => tic.Id == todoCollection.Id) - .Include(tdc => tdc.TodoItems).SingleOrDefault().TodoItems; - - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(2, updatedTodoItems.Count); - } - - [Fact] - public async Task Can_Update_ToMany_Relationship_ThroughLink() + public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() { // Arrange - var person = _personFaker.Generate(); - _context.People.Add(person); - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - var content = new + await _testContext.RunOnDatabaseAsync(async dbContext => { - data = new List - { - new { - type = "todoItems", - id = $"{todoItem.Id}" - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}/relationships/todoItems"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - _context = _fixture.GetRequiredService(); - var personsTodoItems = _context.People.Include(p => p.TodoItems).Single(p => p.Id == person.Id).TodoItems; - - Assert.NotEmpty(personsTodoItems); - } - - [Fact] - public async Task Can_Update_ToOne_Relationship_ThroughLink() - { - // Arrange - var person = _personFaker.Generate(); - _context.People.Add(person); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(person); - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - var todoItemsOwner = _context.TodoItems.Include(t => t.Owner).Single(t => t.Id == todoItem.Id); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(todoItemsOwner); - } - - [Fact] - public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() - { - // Arrange - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - - _context.People.Add(person); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new + var requestBody = new { data = new { - id = todoItem.Id, type = "todoItems", - relationships = new - { - owner = new - { - data = (object)null - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - // Assert - var todoItemResult = _context.TodoItems - .AsNoTracking() - .Include(t => t.Owner) - .Single(t => t.Id == todoItem.Id); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Null(todoItemResult.Owner); - } - - [Fact] - public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() - { - // Arrange - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - person.TodoItems = new HashSet { todoItem }; - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var content = new - { - data = new - { - id = person.Id, - type = "people", + id = todoItem.StringId, relationships = new Dictionary { - { "todoItems", new + ["dependentOnTodo"] = new + { + data = new { - data = new List() + type = "todoItems", + id = todoItem.StringId } } } } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act - var response = await _fixture.Client.SendAsync(request); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - var personResult = _context.People - .AsNoTracking() - .Include(p => p.TodoItems) - .Single(p => p.Id == person.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.DependentOnTodo) + .FirstAsync(item => item.Id == todoItem.Id); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Empty(personResult.TodoItems); + todoItemInDatabase.DependentOnTodo.Id.Should().Be(todoItem.Id); + }); } [Fact] - public async Task Can_Delete_Relationship_By_Patching_Relationship() + public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patching_Resource() { // Arrange - var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - - _context.People.Add(person); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var content = new - { - data = (object)null - }; + var otherTodoItem = _todoItemFaker.Generate(); - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) + await _testContext.RunOnDatabaseAsync(async dbContext => { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - // Assert - var todoItemResult = _context.TodoItems - .AsNoTracking() - .Include(t => t.Owner) - .Single(t => t.Id == todoItem.Id); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Null(todoItemResult.Owner); - } + dbContext.TodoItems.AddRange(todoItem, otherTodoItem); + await dbContext.SaveChangesAsync(); + }); - [Fact] - public async Task Updating_ToOne_Relationship_With_Implicit_Remove() - { - // Arrange - var context = _fixture.GetRequiredService(); - var passport = new Passport(context); - var person1 = _personFaker.Generate(); - person1.Passport = passport; - var person2 = _personFaker.Generate(); - context.People.AddRange(new List { person1, person2 }); - await context.SaveChangesAsync(); - var passportId = person1.PassportId; - var content = new + var requestBody = new { data = new { - type = "people", - id = person2.Id, + type = "todoItems", + id = todoItem.StringId, relationships = new Dictionary { - { "passport", new + ["dependentOnTodo"] = new + { + data = new { - data = new { type = "passports", id = $"{passport.StringId}" } + type = "todoItems", + id = todoItem.StringId } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person2.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - // Assert - - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - var dbPerson = context.People.AsNoTracking().Where(p => p.Id == person2.Id).Include("Passport").FirstOrDefault(); - Assert.Equal(passportId, dbPerson.Passport.Id); - } - - [Fact] - public async Task Updating_ToMany_Relationship_With_Implicit_Remove() - { - // Arrange - var context = _fixture.GetRequiredService(); - var person1 = _personFaker.Generate(); - person1.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); - var person2 = _personFaker.Generate(); - person2.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - context.People.AddRange(new List { person1, person2 }); - await context.SaveChangesAsync(); - var todoItem1Id = person1.TodoItems.ElementAt(0).Id; - var todoItem2Id = person1.TodoItems.ElementAt(1).Id; - - var content = new - { - data = new - { - type = "people", - id = person2.Id, - relationships = new Dictionary - { - { "todoItems", new + }, + ["childrenTodos"] = new + { + data = new[] { - data = new List + new + { + type = "todoItems", + id = todoItem.StringId + }, + new { - new { - type = "todoItems", - id = $"{todoItem1Id}" - }, - new { - type = "todoItems", - id = $"{todoItem2Id}" - } + type = "todoItems", + id = otherTodoItem.StringId } } } @@ -749,102 +193,22 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() } }; - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person2.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + var route = "/api/v1/todoItems/" + todoItem.StringId; // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - var dbPerson = context.People.AsNoTracking().Where(p => p.Id == person2.Id).Include("TodoItems").FirstOrDefault(); - Assert.Equal(2, dbPerson.TodoItems.Count); - Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem1Id)); - Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem2Id)); - } - - [Fact] - public async Task Fails_On_Unknown_Relationship() - { - // Arrange - var person = _personFaker.Generate(); - _context.People.Add(person); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(person); - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Fails_On_Missing_Resource() - { - // Arrange - var person = _personFaker.Generate(); - _context.People.Add(person); - - await _context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var serializer = _fixture.GetSerializer(p => new { }); - var content = serializer.Serialize(person); - - var httpMethod = new HttpMethod("PATCH"); - var route = "/api/v1/todoItems/99999999/relationships/owner"; - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var todoItemInDatabase = await dbContext.TodoItems + .Include(item => item.ParentTodo) + .FirstAsync(item => item.Id == todoItem.Id); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); + todoItemInDatabase.ParentTodo.Id.Should().Be(todoItem.Id); + }); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs index 5868e34fc0..8054663ef3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs @@ -86,7 +86,8 @@ public void ReloadDbContext() public void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) { - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {response.Content.ReadAsStringAsync().Result}"); + var responseBody = response.Content.ReadAsStringAsync().Result; + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); } private bool disposedValue; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs index 9a75fcbb82..b6bbaf3d0a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -32,6 +31,7 @@ public TodoItemControllerTests(TestFixture fixture) { _fixture = fixture; _context = fixture.GetRequiredService(); + _todoItemFaker = new Faker() .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()) @@ -47,20 +47,20 @@ public TodoItemControllerTests(TestFixture fixture) public async Task Can_Get_TodoItems_Paginate_Check() { // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); var expectedResourcesPerPage = _fixture.GetRequiredService().DefaultPageSize.Value; - var person = new Person(); + + var person = _personFaker.Generate(); var todoItems = _todoItemFaker.Generate(expectedResourcesPerPage + 1); foreach (var todoItem in todoItems) { todoItem.Owner = person; _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - } + await _context.ClearTableAsync(); + await _context.SaveChangesAsync(); + var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems"; var request = new HttpRequestMessage(httpMethod, route); @@ -80,9 +80,9 @@ public async Task Can_Get_TodoItems_Paginate_Check() public async Task Can_Get_TodoItem_ById() { // Arrange - var person = new Person(); var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -108,14 +108,10 @@ public async Task Can_Get_TodoItem_ById() public async Task Can_Post_TodoItem() { // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - var serializer = _fixture.GetSerializer(e => new { e.Description, e.OffsetDate, e.Ordinal, e.CreatedDate }, e => new { e.Owner }); + var nowOffset = new DateTimeOffset(); var todoItem = _todoItemFaker.Generate(); - var nowOffset = new DateTimeOffset(); todoItem.OffsetDate = nowOffset; var httpMethod = new HttpMethod("POST"); @@ -145,13 +141,14 @@ public async Task Can_Post_TodoItem() public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() { // Arrange - var person1 = new Person(); - var person2 = new Person(); - _context.People.Add(person1); - _context.People.Add(person2); + var person1 = _personFaker.Generate(); + var person2 = _personFaker.Generate(); + + _context.People.AddRange(person1, person2); await _context.SaveChangesAsync(); var todoItem = _todoItemFaker.Generate(); + var content = new { data = new @@ -204,22 +201,22 @@ public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() var resultId = int.Parse(document.SingleData.Id); // Assert -- database - var todoItemResult = await _context.TodoItems.SingleAsync(t => t.Id == resultId); + var todoItemResult = await _context.TodoItems + .Include(t => t.Owner) + .Include(t => t.Assignee) + .SingleAsync(t => t.Id == resultId); - Assert.Equal(person1.Id, todoItemResult.OwnerId); - Assert.Equal(person2.Id, todoItemResult.AssigneeId); + Assert.Equal(person1.Id, todoItemResult.Owner.Id); + Assert.Equal(person2.Id, todoItemResult.Assignee.Id); } [Fact] public async Task Can_Patch_TodoItem() { // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -267,13 +264,10 @@ public async Task Can_Patch_TodoItem() public async Task Can_Patch_TodoItemWithNullable() { // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - var todoItem = _todoItemFaker.Generate(); todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -322,13 +316,10 @@ public async Task Can_Patch_TodoItemWithNullable() public async Task Can_Patch_TodoItemWithNullValue() { // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - var todoItem = _todoItemFaker.Generate(); todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -371,32 +362,5 @@ public async Task Can_Patch_TodoItemWithNullValue() Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); Assert.Null(deserializedBody.AchievedDate); } - - [Fact] - public async Task Can_Delete_TodoItem() - { - // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(string.Empty)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - Assert.Null(_context.TodoItems.FirstOrDefault(t => t.Id == todoItem.Id)); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs b/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs index f2c69db509..4fb18a008b 100644 --- a/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs +++ b/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs @@ -26,9 +26,9 @@ public void Dispose() internal sealed class FakeLogger : ILogger { - private readonly ConcurrentBag<(LogLevel LogLevel, string Text)> _messages = new ConcurrentBag<(LogLevel LogLevel, string Text)>(); + private readonly ConcurrentBag _messages = new ConcurrentBag(); - public IReadOnlyCollection<(LogLevel LogLevel, string Text)> Messages => _messages; + public IReadOnlyCollection Messages => _messages; public bool IsEnabled(LogLevel logLevel) => true; @@ -41,10 +41,22 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except Func formatter) { var message = formatter(state, exception); - _messages.Add((logLevel, message)); + _messages.Add(new LogMessage(logLevel, message)); } public IDisposable BeginScope(TState state) => null; } + + internal sealed class LogMessage + { + public LogLevel LogLevel { get; } + public string Text { get; } + + public LogMessage(LogLevel logLevel, string text) + { + LogLevel = logLevel; + Text = text; + } + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs index 44a347fc63..428c44586d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs @@ -120,9 +120,9 @@ public async Task RunOnDatabaseAsync(Func asyncAction) } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecuteDeleteAsync(string requestUrl) + ExecuteDeleteAsync(string requestUrl, object requestBody = null) { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl); + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody); } private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs new file mode 100644 index 0000000000..70591f317f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs @@ -0,0 +1,44 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class Car : Identifiable + { + [NotMapped] + public override string Id + { + get => $"{RegionId}:{LicensePlate}"; + set + { + var elements = value.Split(':'); + if (elements.Length == 2) + { + if (int.TryParse(elements[0], out int regionId)) + { + RegionId = regionId; + LicensePlate = elements[1]; + } + } + else + { + throw new InvalidOperationException($"Failed to convert ID '{value}'."); + } + } + } + + [Attr] + public string LicensePlate { get; set; } + + [Attr] + public long RegionId { get; set; } + + [HasOne] + public Engine Engine { get; set; } + + [HasOne] + public Dealership Dealership { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs new file mode 100644 index 0000000000..4251351818 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + /// + /// Rewrites an expression tree, updating all references to with + /// the combination of and . + /// + /// + /// This enables queries to use , which is not mapped in the database. + /// + public sealed class CarExpressionRewriter : QueryExpressionRewriter + { + private readonly AttrAttribute _regionIdAttribute; + private readonly AttrAttribute _licensePlateAttribute; + + public CarExpressionRewriter(IResourceContextProvider resourceContextProvider) + { + var carResourceContext = resourceContextProvider.GetResourceContext(); + + _regionIdAttribute = + carResourceContext.Attributes.Single(attribute => + attribute.Property.Name == nameof(Car.RegionId)); + + _licensePlateAttribute = + carResourceContext.Attributes.Single(attribute => + attribute.Property.Name == nameof(Car.LicensePlate)); + } + + public override QueryExpression VisitComparison(ComparisonExpression expression, object argument) + { + if (expression.Left is ResourceFieldChainExpression leftChain && + expression.Right is LiteralConstantExpression rightConstant) + { + PropertyInfo leftProperty = leftChain.Fields.Last().Property; + if (IsCarId(leftProperty)) + { + if (expression.Operator != ComparisonOperator.Equals) + { + throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); + } + + return RewriteFilterOnCarStringIds(leftChain, new[] {rightConstant.Value}); + } + } + + return base.VisitComparison(expression, argument); + } + + public override QueryExpression VisitEqualsAnyOf(EqualsAnyOfExpression expression, object argument) + { + PropertyInfo property = expression.TargetAttribute.Fields.Last().Property; + if (IsCarId(property)) + { + var carStringIds = expression.Constants.Select(constant => constant.Value).ToArray(); + return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); + } + + return base.VisitEqualsAnyOf(expression, argument); + } + + public override QueryExpression VisitMatchText(MatchTextExpression expression, object argument) + { + PropertyInfo property = expression.TargetAttribute.Fields.Last().Property; + if (IsCarId(property)) + { + throw new NotSupportedException("Partial text matching on Car IDs is not possible."); + } + + return base.VisitMatchText(expression, argument); + } + + private static bool IsCarId(PropertyInfo property) + { + return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); + } + + private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression existingCarIdChain, + IEnumerable carStringIds) + { + var outerTerms = new List(); + + foreach (var carStringId in carStringIds) + { + var tempCar = new Car + { + StringId = carStringId + }; + + var keyComparison = + CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate); + outerTerms.Add(keyComparison); + } + + return outerTerms.Count == 1 ? outerTerms[0] : new LogicalExpression(LogicalOperator.Or, outerTerms); + } + + private QueryExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldChainExpression existingCarIdChain, + long regionIdValue, string licensePlateValue) + { + var regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute); + var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, + new LiteralConstantExpression(regionIdValue.ToString())); + + var licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute); + var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, + new LiteralConstantExpression(licensePlateValue)); + + return new LogicalExpression(LogicalOperator.And, new[] + { + regionIdComparison, + licensePlateComparison + }); + } + + public override QueryExpression VisitSort(SortExpression expression, object argument) + { + var newSortElements = new List(); + + foreach (var sortElement in expression.Elements) + { + if (IsSortOnCarId(sortElement)) + { + var regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute, _regionIdAttribute); + newSortElements.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); + + var licensePlateSort = + ReplaceLastAttributeInChain(sortElement.TargetAttribute, _licensePlateAttribute); + newSortElements.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); + } + else + { + newSortElements.Add(sortElement); + } + } + + return new SortExpression(newSortElements); + } + + private static bool IsSortOnCarId(SortElementExpression sortElement) + { + if (sortElement.TargetAttribute != null) + { + PropertyInfo property = sortElement.TargetAttribute.Fields.Last().Property; + if (IsCarId(property)) + { + return true; + } + } + + return false; + } + + private static ResourceFieldChainExpression ReplaceLastAttributeInChain( + ResourceFieldChainExpression resourceFieldChain, AttrAttribute attribute) + { + var fields = resourceFieldChain.Fields.ToList(); + fields[^1] = attribute; + return new ResourceFieldChainExpression(fields); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs new file mode 100644 index 0000000000..671164b31f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class CarRepository : EntityFrameworkCoreRepository + { + private readonly IResourceGraph _resourceGraph; + + public CarRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, IGetResourcesByIds getResourcesByIds, + ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, getResourcesByIds, loggerFactory) + { + _resourceGraph = resourceGraph; + } + + protected override IQueryable ApplyQueryLayer(QueryLayer layer) + { + RecursiveRewriteFilterInLayer(layer); + + return base.ApplyQueryLayer(layer); + } + + private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) + { + if (queryLayer.Filter != null) + { + var writer = new CarExpressionRewriter(_resourceGraph); + queryLayer.Filter = (FilterExpression) writer.Visit(queryLayer.Filter, null); + } + + if (queryLayer.Sort != null) + { + var writer = new CarExpressionRewriter(_resourceGraph); + queryLayer.Sort = (SortExpression) writer.Visit(queryLayer.Sort, null); + } + + if (queryLayer.Projection != null) + { + foreach (QueryLayer nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) + { + RecursiveRewriteFilterInLayer(nextLayer); + } + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs new file mode 100644 index 0000000000..f264c043e3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class CarsController : JsonApiController + { + public CarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs new file mode 100644 index 0000000000..1a9017472e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class CompositeDbContext : DbContext + { + public DbSet Cars { get; set; } + public DbSet Engines { get; set; } + public DbSet Dealerships { get; set; } + + public CompositeDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(car => new {car.RegionId, car.LicensePlate}); + + modelBuilder.Entity() + .HasOne(engine => engine.Car) + .WithOne(car => car.Engine) + .HasForeignKey(); + + modelBuilder.Entity() + .HasMany(dealership => dealership.Inventory) + .WithOne(car => car.Dealership); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs new file mode 100644 index 0000000000..b40a17fd7e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -0,0 +1,572 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class CompositeKeyTests + : IClassFixture, CompositeDbContext>> + { + private readonly IntegrationTestContext, CompositeDbContext> _testContext; + + public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped, CarRepository>(); + services.AddScoped, CarRepository>(); + }); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_filter_on_ID_in_primary_resources() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars?filter=any(id,'123:AA-BB-11','999:XX-YY-22')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(car.StringId); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars/" + car.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(car.StringId); + } + + [Fact] + public async Task Can_sort_on_ID() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars?sort=id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(car.StringId); + } + + [Fact] + public async Task Can_select_ID() + { + // Arrange + var car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars?fields=id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(car.StringId); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "cars", + attributes = new + { + regionId = 123, + licensePlate = "AA-BB-11" + } + } + }; + + var route = "/cars"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_create_OneToOne_relationship() + { + // Arrange + var existingCar = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + var existingEngine = new Engine + { + SerialCode = "1234567890" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingCar, existingEngine); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "engines", + id = existingEngine.StringId, + relationships = new + { + car = new + { + data = new + { + type = "cars", + id = existingCar.StringId + } + } + } + } + }; + + var route = "/engines/" + existingEngine.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var engineInDatabase = await dbContext.Engines + .Include(engine => engine.Car) + .FirstAsync(engine => engine.Id == existingEngine.Id); + + engineInDatabase.Car.Should().NotBeNull(); + engineInDatabase.Car.Id.Should().Be(existingCar.StringId); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship() + { + // Arrange + var existingEngine = new Engine + { + SerialCode = "1234567890", + Car = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Engines.Add(existingEngine); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "engines", + id = existingEngine.StringId, + relationships = new + { + car = new + { + data = (object) null + } + } + } + }; + + var route = "/engines/" + existingEngine.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var engineInDatabase = await dbContext.Engines + .Include(engine => engine.Car) + .FirstAsync(engine => engine.Id == existingEngine.Id); + + engineInDatabase.Car.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship() + { + // Arrange + var existingDealership = new Dealership + { + Address = "Dam 1, 1012JS Amsterdam, the Netherlands", + Inventory = new HashSet + { + new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }, + new Car + { + RegionId = 456, + LicensePlate = "CC-DD-22" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Dealerships.Add(existingDealership); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "cars", + id = "123:AA-BB-11" + } + } + }; + + var route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var dealershipInDatabase = await dbContext.Dealerships + .Include(dealership => dealership.Inventory) + .FirstOrDefaultAsync(dealership => dealership.Id == existingDealership.Id); + + dealershipInDatabase.Inventory.Should().HaveCount(1); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_add_to_OneToMany_relationship() + { + // Arrange + var existingDealership = new Dealership + { + Address = "Dam 1, 1012JS Amsterdam, the Netherlands" + }; + var existingCar = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingDealership, existingCar); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "cars", + id = "123:AA-BB-11" + } + } + }; + + var route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var dealershipInDatabase = await dbContext.Dealerships + .Include(dealership => dealership.Inventory) + .FirstOrDefaultAsync(dealership => dealership.Id == existingDealership.Id); + + dealershipInDatabase.Inventory.Should().HaveCount(1); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship() + { + // Arrange + var existingDealership = new Dealership + { + Address = "Dam 1, 1012JS Amsterdam, the Netherlands", + Inventory = new HashSet + { + new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }, + new Car + { + RegionId = 456, + LicensePlate = "CC-DD-22" + } + } + }; + var existingCar = new Car + { + RegionId = 789, + LicensePlate = "EE-FF-33" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingDealership, existingCar); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "cars", + id = "123:AA-BB-11" + }, + new + { + type = "cars", + id = "789:EE-FF-33" + } + + } + }; + + var route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var dealershipInDatabase = await dbContext.Dealerships + .Include(dealership => dealership.Inventory) + .FirstOrDefaultAsync(dealership => dealership.Id == existingDealership.Id); + + dealershipInDatabase.Inventory.Should().HaveCount(2); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(0).Id); + }); + } + + [Fact] + public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relationship_ID() + { + // Arrange + var existingDealership = new Dealership + { + Address = "Dam 1, 1012JS Amsterdam, the Netherlands", + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Dealerships.Add(existingDealership); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "cars", + id = "999:XX-YY-22" + } + } + }; + + var route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'cars' with ID '999:XX-YY-22' in relationship 'inventory' does not exist."); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var existingCar = new Car + { + RegionId = 123, + LicensePlate = "AA-BB-11" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(existingCar); + await dbContext.SaveChangesAsync(); + }); + + var route = "/cars/" + existingCar.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var carInDatabase = await dbContext.Cars + .FirstOrDefaultAsync(car => car.RegionId == existingCar.RegionId && car.LicensePlate == existingCar.LicensePlate); + + carInDatabase.Should().BeNull(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs new file mode 100644 index 0000000000..b8c845dc7c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class Dealership : Identifiable + { + [Attr] + public string Address { get; set; } + + [HasMany] + public ISet Inventory { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs new file mode 100644 index 0000000000..53b4f281e1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class DealershipsController : JsonApiController + { + public DealershipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs new file mode 100644 index 0000000000..33ecaf4b6c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class Engine : Identifiable + { + [Attr] + public string SerialCode { get; set; } + + [HasOne] + public Car Car { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs new file mode 100644 index 0000000000..4833292cd8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys +{ + public sealed class EnginesController : JsonApiController + { + public EnginesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs index 43a105b644..b94e3d9b45 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -63,7 +63,7 @@ public async Task Cannot_filter_in_unknown_nested_scope() } [Fact] - public async Task Cannot_filter_on_blocked_attribute() + public async Task Cannot_filter_on_attribute_with_blocked_capability() { // Arrange var route = "/api/v1/todoItems?filter=equals(achievedDate,null)"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index 768b98ae16..00a281ff6f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -765,7 +765,7 @@ public async Task Cannot_include_unknown_nested_relationship() } [Fact] - public async Task Cannot_include_blocked_relationship() + public async Task Cannot_include_relationship_with_blocked_capability() { // Arrange var route = "/api/v1/people?include=unIncludeableItem"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs new file mode 100644 index 0000000000..61c99f6bb7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + public sealed class LoggingTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public LoggingTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + FakeLoggerFactory loggerFactory = null; + + testContext.ConfigureLogging(options => + { + loggerFactory = new FakeLoggerFactory(); + + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Trace); + options.AddFilter((category, level) => level == LogLevel.Trace && + (category == typeof(JsonApiReader).FullName || category == typeof(JsonApiWriter).FullName)); + }); + + testContext.ConfigureServicesBeforeStartup(services => + { + if (loggerFactory != null) + { + services.AddSingleton(_ => loggerFactory); + } + }); + } + + [Fact] + public async Task Logs_request_body_on_error() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + // Arrange + var requestBody = "{ \"data\" {"; + + var route = "/api/v1/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + loggerFactory.Logger.Messages.Should().HaveCount(2); + loggerFactory.Logger.Messages.Should().Contain(message => message.Text.StartsWith("Received request at ") && message.Text.Contains("with body:")); + loggerFactory.Logger.Messages.Should().Contain(message => message.Text.StartsWith("Sending 422 response for request at ") && message.Text.Contains("Failed to deserialize request body.")); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs similarity index 93% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 60690f1758..ab33245072 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -10,11 +10,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class ResourceTests : IClassFixture> + public sealed class ResourceMetaTests : IClassFixture> { private readonly IntegrationTestContext _testContext; - public ResourceTests(IntegrationTestContext testContext) + public ResourceMetaTests(IntegrationTestContext testContext) { _testContext = testContext; } @@ -60,7 +60,7 @@ public async Task ResourceDefinition_That_Implements_GetMeta_Contains_Include_Me { TodoItems = new HashSet { - new TodoItem {Id = 1, Description = "Important: Pay the bills"}, + new TodoItem {Id = 1, Description = "Important: Pay the bills"} } }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 5f2d74715a..f9b05f28a7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; +using Microsoft.EntityFrameworkCore; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation @@ -21,19 +21,18 @@ public ModelStateValidationTests(IntegrationTestContext + attributes = new { - ["isCaseSensitive"] = "true" + isCaseSensitive = true } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -53,20 +52,19 @@ public async Task When_posting_resource_with_omitted_required_attribute_value_it public async Task When_posting_resource_with_null_for_required_attribute_value_it_must_fail() { // Arrange - var content = new + var requestBody = new { data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = null, - ["isCaseSensitive"] = "true" + name = (string) null, + isCaseSensitive = true } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -86,20 +84,19 @@ public async Task When_posting_resource_with_null_for_required_attribute_value_i public async Task When_posting_resource_with_invalid_attribute_value_it_must_fail() { // Arrange - var content = new + var requestBody = new { data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = "!@#$%^&*().-", - ["isCaseSensitive"] = "true" + name = "!@#$%^&*().-", + isCaseSensitive = true } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -119,20 +116,19 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_fai public async Task When_posting_resource_with_valid_attribute_value_it_must_succeed() { // Arrange - var content = new + var requestBody = new { data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = "Projects", - ["isCaseSensitive"] = "true" + name = "Projects", + isCaseSensitive = true } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -150,19 +146,18 @@ public async Task When_posting_resource_with_valid_attribute_value_it_must_succe public async Task When_posting_resource_with_multiple_violations_it_must_fail() { // Arrange - var content = new + var requestBody = new { data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["sizeInBytes"] = "-1" + sizeInBytes = -1 } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -219,19 +214,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", - attributes = new Dictionary + attributes = new { - ["name"] = "Projects", - ["isCaseSensitive"] = "true" + name = "Projects", + isCaseSensitive = true }, - relationships = new Dictionary + relationships = new { - ["subdirectories"] = new + subdirectories = new { data = new[] { @@ -242,7 +237,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["files"] = new + files = new { data = new[] { @@ -253,7 +248,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["parent"] = new + parent = new { data = new { @@ -265,7 +260,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -279,6 +273,51 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["isCaseSensitive"].Should().Be(true); } + [Fact] + public async Task When_posting_annotated_to_many_relationship_it_must_succeed() + { + // Arrange + var directory = new SystemDirectory + { + Name="Projects", + IsCaseSensitive = true + }; + + var file = new SystemFile + { + FileName = "Main.cs", + SizeInBytes = 100 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(directory, file); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "systemFiles", + id = file.StringId + } + } + }; + + string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + [Fact] public async Task When_patching_resource_with_omitted_required_attribute_value_it_must_succeed() { @@ -295,29 +334,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["sizeInBytes"] = "100" + sizeInBytes = 100 } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); } [Fact] @@ -336,20 +374,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = null + name = (string) null } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -381,20 +418,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "!@#$%^&*().-" + name = "!@#$%^&*().-" } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act @@ -426,19 +462,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = -1, - attributes = new Dictionary + attributes = new { - ["name"] = "Repositories" + name = "Repositories" }, - relationships = new Dictionary + relationships = new { - ["subdirectories"] = new + subdirectories = new { data = new[] { @@ -453,7 +489,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/-1"; // Act @@ -491,29 +526,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "Repositories" + name = "Repositories" } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); } [Fact] @@ -571,19 +605,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "Project Files" + name = "Project Files" }, - relationships = new Dictionary + relationships = new { - ["subdirectories"] = new + subdirectories = new { data = new[] { @@ -594,7 +628,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["files"] = new + files = new { data = new[] { @@ -605,7 +639,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["parent"] = new + parent = new { data = new { @@ -617,16 +651,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); } [Fact] @@ -645,19 +678,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "Project files" + name = "Project files" }, - relationships = new Dictionary + relationships = new { - ["self"] = new + self = new { data = new { @@ -665,7 +698,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = directory.StringId } }, - ["alsoSelf"] = new + alsoSelf = new { data = new { @@ -677,16 +710,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); } [Fact] @@ -705,19 +737,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "Project files" + name = "Project files" }, - relationships = new Dictionary + relationships = new { - ["subdirectories"] = new + subdirectories = new { data = new[] { @@ -732,16 +764,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); } [Fact] @@ -771,7 +802,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -780,16 +811,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId + "/relationships/parent"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var directoryInDatabase = await dbContext.Directories + .Include(d => d.Parent) + .FirstAsync(d => d.Id == directory.Id); - responseDocument.Data.Should().BeNull(); + directoryInDatabase.Parent.Id.Should().Be(otherParent.Id); + }); } [Fact] @@ -826,7 +865,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new[] { @@ -838,16 +877,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId + "/relationships/files"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task When_deleting_annotated_to_many_relationship_it_must_succeed() + { + // Arrange + var directory = new SystemDirectory + { + Name="Projects", + IsCaseSensitive = true, + Files = new List + { + new SystemFile + { + FileName = "Main.cs", + SizeInBytes = 100 + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(directory); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 86a22b14dc..33619cb477 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -1,9 +1,7 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation @@ -21,20 +19,19 @@ public NoModelStateValidationTests(IntegrationTestContext + attributes = new { - ["name"] = "!@#$%^&*().-", - ["isCaseSensitive"] = "false" + name = "!@#$%^&*().-", + isCaseSensitive = "false" } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -63,29 +60,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { type = "systemDirectories", id = directory.StringId, - attributes = new Dictionary + attributes = new { - ["name"] = "!@#$%^&*().-" + name = "!@#$%^&*().-" } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/" + directory.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index ac3c661242..25cd2b201a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -26,7 +26,7 @@ public ResourceDefinitionQueryCallbackTests(IntegrationTestContext(); @@ -333,8 +333,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); - responseDocument.SingleData.Attributes.Should().NotContainKey("percentageComplete"); responseDocument.SingleData.Attributes["status"].Should().Be("5% completed."); } @@ -396,8 +396,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); - responseDocument.SingleData.Attributes.Should().NotContainKey("riskLevel"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs index 530cf8bbc2..f21eadc1af 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs @@ -8,13 +8,9 @@ public sealed class InheritanceDbContext : DbContext public InheritanceDbContext(DbContextOptions options) : base(options) { } public DbSet Humans { get; set; } - public DbSet Men { get; set; } - public DbSet CompanyHealthInsurances { get; set; } - public DbSet ContentItems { get; set; } - public DbSet HumanFavoriteContentItems { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -35,7 +31,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasValue(2); modelBuilder.Entity() - .HasKey(hfci => new { ContentPersonId = hfci.ContentItemId, PersonId = hfci.HumanId }); + .HasKey(item => new { ContentPersonId = item.ContentItemId, PersonId = item.HumanId }); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 2dbc6881c9..f2c2c6001e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -20,119 +19,252 @@ public InheritanceTests(IntegrationTestContext(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("men"); + responseDocument.SingleData.Attributes["familyName"].Should().Be(man.FamilyName); + responseDocument.SingleData.Attributes["isRetired"].Should().Be(man.IsRetired); + responseDocument.SingleData.Attributes["hasBeard"].Should().Be(man.HasBeard); + + var newManId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var manInDatabase = await dbContext.Men + .FirstAsync(m => m.Id == newManId); + + manInDatabase.FamilyName.Should().Be(man.FamilyName); + manInDatabase.IsRetired.Should().Be(man.IsRetired); + manInDatabase.HasBeard.Should().Be(man.HasBeard); + }); + } + + [Fact] + public async Task Can_create_resource_with_ToOne_relationship() + { + // Arrange + var existingInsurance = new CompanyHealthInsurance(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.CompanyHealthInsurances.Add(insurance); + dbContext.CompanyHealthInsurances.Add(existingInsurance); + await dbContext.SaveChangesAsync(); }); - var route = "/men"; var requestBody = new { data = new { type = "men", - relationships = new Dictionary + relationships = new { + healthInsurance = new { - "healthInsurance", new + data = new { - data = new { type = "companyHealthInsurances", id = insurance.StringId } + type = "companyHealthInsurances", + id = existingInsurance.StringId } } } } }; + var route = "/men"; + // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.SingleData.Should().NotBeNull(); + var newManId = int.Parse(responseDocument.SingleData.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertMan = await dbContext.Men - .Include(m => m.HealthInsurance) - .SingleAsync(m => m.Id == int.Parse(responseDocument.SingleData.Id)); + var manInDatabase = await dbContext.Men + .Include(man => man.HealthInsurance) + .FirstAsync(man => man.Id == newManId); - assertMan.HealthInsurance.Should().BeOfType(); + manInDatabase.HealthInsurance.Should().BeOfType(); + manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); }); } - + [Fact] - public async Task Can_patch_resource_with_to_one_relationship_through_relationship_link() + public async Task Can_update_resource_through_primary_endpoint() { // Arrange - var man = new Man(); - var insurance = new CompanyHealthInsurance(); + var existingMan = new Man + { + FamilyName = "Smith", + IsRetired = false, + HasBeard = true + }; + + var newMan = new Man + { + FamilyName = "Jackson", + IsRetired = true, + HasBeard = false + }; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTablesAsync(); - dbContext.AddRange(man, insurance); + dbContext.Men.Add(existingMan); await dbContext.SaveChangesAsync(); }); - - var route = $"/men/{man.Id}/relationships/healthInsurance"; var requestBody = new { - data = new { type = "companyHealthInsurances", id = insurance.StringId } + data = new + { + type = "men", + id = existingMan.StringId, + attributes = new + { + familyName = newMan.FamilyName, + isRetired = newMan.IsRetired, + hasBeard = newMan.HasBeard + } + } }; + var route = "/men/" + existingMan.StringId; + // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertMan = await dbContext.Men - .Include(m => m.HealthInsurance) - .SingleAsync(h => h.Id == man.Id); + var manInDatabase = await dbContext.Men + .FirstAsync(man => man.Id == existingMan.Id); - assertMan.HealthInsurance.Should().BeOfType(); + manInDatabase.FamilyName.Should().Be(newMan.FamilyName); + manInDatabase.IsRetired.Should().Be(newMan.IsRetired); + manInDatabase.HasBeard.Should().Be(newMan.HasBeard); }); } + [Fact] + public async Task Can_update_resource_with_ToOne_relationship_through_relationship_endpoint() + { + // Arrange + var existingMan = new Man(); + var existingInsurance = new CompanyHealthInsurance(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTablesAsync(); + dbContext.AddRange(existingMan, existingInsurance); + + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "companyHealthInsurances", + id = existingInsurance.StringId + } + }; + + var route = $"/men/{existingMan.StringId}/relationships/healthInsurance"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var manInDatabase = await dbContext.Men + .Include(man => man.HealthInsurance) + .FirstAsync(man => man.Id == existingMan.Id); + + manInDatabase.HealthInsurance.Should().BeOfType(); + manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); + }); + } [Fact] - public async Task Can_create_resource_with_to_many_relationship() + public async Task Can_create_resource_with_ToMany_relationship() { // Arrange - var father = new Man(); - var mother = new Woman(); + var existingFather = new Man(); + var existingMother = new Woman(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.Humans.AddRange(father, mother); + dbContext.Humans.AddRange(existingFather, existingMother); + await dbContext.SaveChangesAsync(); }); - var route = "/men"; var requestBody = new { data = new { type = "men", - relationships = new Dictionary + relationships = new { + parents = new { - "parents", new + data = new[] { - data = new[] + new + { + type = "men", + id = existingFather.StringId + }, + new { - new { type = "men", id = father.StringId }, - new { type = "women", id = mother.StringId } + type = "women", + id = existingMother.StringId } } } @@ -140,163 +272,203 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; + var route = "/men"; + // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.SingleData.Should().NotBeNull(); + var newManId = int.Parse(responseDocument.SingleData.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertMan = await dbContext.Men - .Include(m => m.Parents) - .SingleAsync(m => m.Id == int.Parse(responseDocument.SingleData.Id)); + var manInDatabase = await dbContext.Men + .Include(man => man.Parents) + .FirstAsync(man => man.Id == newManId); - assertMan.Parents.Should().HaveCount(2); - assertMan.Parents.Should().ContainSingle(h => h is Man); - assertMan.Parents.Should().ContainSingle(h => h is Woman); + manInDatabase.Parents.Should().HaveCount(2); + manInDatabase.Parents.Should().ContainSingle(human => human is Man); + manInDatabase.Parents.Should().ContainSingle(human => human is Woman); }); } [Fact] - public async Task Can_patch_resource_with_to_many_relationship_through_relationship_link() + public async Task Can_update_resource_with_ToMany_relationship_through_relationship_endpoint() { - // Arrange - var child = new Man(); - var father = new Man(); - var mother = new Woman(); + // Arrange + var existingChild = new Man(); + var existingFather = new Man(); + var existingMother = new Woman(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.Humans.AddRange(child, father, mother); + dbContext.Humans.AddRange(existingChild, existingFather, existingMother); + await dbContext.SaveChangesAsync(); }); - - var route = $"/men/{child.StringId}/relationships/parents"; + var requestBody = new { data = new[] { - new { type = "men", id = father.StringId }, - new { type = "women", id = mother.StringId } + new + { + type = "men", + id = existingFather.StringId + }, + new + { + type = "women", + id = existingMother.StringId + } } }; - + + var route = $"/men/{existingChild.StringId}/relationships/parents"; + // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - var assertChild = await dbContext.Men - .Include(m => m.Parents) - .SingleAsync(m => m.Id == child.Id); + var manInDatabase = await dbContext.Men + .Include(man => man.Parents) + .FirstAsync(man => man.Id == existingChild.Id); - assertChild.Parents.Should().HaveCount(2); - assertChild.Parents.Should().ContainSingle(h => h is Man); - assertChild.Parents.Should().ContainSingle(h => h is Woman); + manInDatabase.Parents.Should().HaveCount(2); + manInDatabase.Parents.Should().ContainSingle(human => human is Man); + manInDatabase.Parents.Should().ContainSingle(human => human is Woman); }); } [Fact] - public async Task Can_create_resource_with_many_to_many_relationship() + public async Task Can_create_resource_with_ManyToMany_relationship() { // Arrange - var book = new Book(); - var video = new Video(); + var existingBook = new Book(); + var existingVideo = new Video(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.ContentItems.AddRange(book, video); + dbContext.ContentItems.AddRange(existingBook, existingVideo); + await dbContext.SaveChangesAsync(); }); - - var route = "/men"; + var requestBody = new { data = new { type = "men", - relationships = new Dictionary + relationships = new { + favoriteContent = new { - "favoriteContent", new + data = new[] { - data = new[] + new { - new { type = "books", id = book.StringId }, - new { type = "videos", id = video.StringId } + type = "books", + id = existingBook.StringId + }, + new + { + type = "videos", + id = existingVideo.StringId } } } } } }; - + + var route = "/men"; + // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.SingleData.Should().NotBeNull(); + var newManId = int.Parse(responseDocument.SingleData.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => { var contentItems = await dbContext.HumanFavoriteContentItems - .Where(hfci => hfci.Human.Id == int.Parse(responseDocument.SingleData.Id)) - .Select(hfci => hfci.ContentItem) + .Where(favorite => favorite.Human.Id == newManId) + .Select(favorite => favorite.ContentItem) .ToListAsync(); - + contentItems.Should().HaveCount(2); - contentItems.Should().ContainSingle(ci => ci is Book); - contentItems.Should().ContainSingle(ci => ci is Video); + contentItems.Should().ContainSingle(item => item is Book); + contentItems.Should().ContainSingle(item => item is Video); }); } [Fact] - public async Task Can_patch_resource_with_many_to_many_relationship_through_relationship_link() + public async Task Can_update_resource_with_ManyToMany_relationship_through_relationship_endpoint() { // Arrange - var book = new Book(); - var video = new Video(); - var man = new Man(); + var existingBook = new Book(); + var existingVideo = new Video(); + var existingMan = new Man(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTablesAsync(); - dbContext.AddRange(book, video, man); + dbContext.AddRange(existingBook, existingVideo, existingMan); + await dbContext.SaveChangesAsync(); }); - - var route = $"/men/{man.Id}/relationships/favoriteContent"; + var requestBody = new { data = new[] { - new { type = "books", id = book.StringId }, - new { type = "videos", id = video.StringId } + new + { + type = "books", + id = existingBook.StringId + }, + new + { + type = "videos", + id = existingVideo.StringId + } } }; - + + var route = $"/men/{existingMan.StringId}/relationships/favoriteContent"; + // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => { var contentItems = await dbContext.HumanFavoriteContentItems - .Where(hfci => hfci.Human.Id == man.Id) - .Select(hfci => hfci.ContentItem) + .Where(favorite => favorite.Human.Id == existingMan.Id) + .Select(favorite => favorite.ContentItem) .ToListAsync(); contentItems.Should().HaveCount(2); - contentItems.Should().ContainSingle(ci => ci is Book); - contentItems.Should().ContainSingle(ci => ci is Video); + contentItems.Should().ContainSingle(item => item is Book); + contentItems.Should().ContainSingle(item => item is Video); }); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs index 01f8136641..e4177e1c91 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/Models/Human.cs @@ -8,14 +8,17 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceInheritance.Mod public abstract class Human : Identifiable { [Attr] - public bool Retired { get; set; } - + public string FamilyName { get; set; } + + [Attr] + public bool IsRetired { get; set; } + [HasOne] public HealthInsurance HealthInsurance { get; set; } - + [HasMany] public ICollection Parents { get; set; } - + [NotMapped] [HasManyThrough(nameof(HumanFavoriteContentItems))] public ICollection FavoriteContent { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 8e4e52e5b4..20d6e77520 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -372,9 +372,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { type = "companies", id = company.StringId, - attributes = new Dictionary + attributes = new { - {"name", "Umbrella Corporation"} + name = "Umbrella Corporation" } } }; @@ -393,48 +393,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); responseDocument.Errors[0].Source.Parameter.Should().BeNull(); } - - [Fact] - public async Task Cannot_update_relationship_for_deleted_parent() - { - // Arrange - var company = new Company - { - IsSoftDeleted = true, - Departments = new List - { - new Department - { - Name = "Marketing" - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Companies.Add(company); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/companies/{company.StringId}/relationships/departments"; - - var requestBody = new - { - data = new object[0] - }; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); - responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); - responseDocument.Errors[0].Source.Parameter.Should().BeNull(); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs index 5ec7869582..d37ff005f4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -663,7 +663,7 @@ public async Task Cannot_sort_in_unknown_nested_scope() } [Fact] - public async Task Cannot_sort_on_blocked_attribute() + public async Task Cannot_sort_on_attribute_with_blocked_capability() { // Arrange var route = "/api/v1/todoItems?sort=achievedDate"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs index 557834c518..d4668e0f9c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets @@ -20,13 +21,13 @@ public ResultCapturingRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IGetResourcesByIds getResourcesByIds, ResourceCaptureStore captureStore) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, - constraintProviders, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, + constraintProviders, getResourcesByIds, loggerFactory) { _captureStore = captureStore; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index d98c85f20f..89f446ea1f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -86,8 +86,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Id.Should().Be(article.StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); - responseDocument.ManyData[0].Attributes.Should().NotContainKey("url"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Caption.Should().Be(article.Caption); @@ -124,8 +124,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["url"].Should().Be(article.Url); - responseDocument.SingleData.Attributes.Should().NotContainKey("caption"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Url.Should().Be(article.Url); @@ -169,8 +169,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Id.Should().Be(blog.Articles[0].StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(blog.Articles[0].Caption); - responseDocument.ManyData[0].Attributes.Should().NotContainKey("url"); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -217,9 +217,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(2); responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); responseDocument.Included[0].Attributes["businessEmail"].Should().Be(article.Author.BusinessEmail); - responseDocument.Included[0].Attributes.Should().NotContainKey("firstName"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -268,8 +268,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["lastName"].Should().Be(author.LastName); responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["caption"].Should().Be(author.Articles[0].Caption); - responseDocument.Included[0].Attributes.Should().NotContainKey("url"); var authorCaptured = (Author) store.Resources.Should().ContainSingle(x => x is Author).And.Subject.Single(); authorCaptured.Id.Should().Be(author.Id); @@ -323,8 +323,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["lastName"].Should().Be(blog.Owner.LastName); responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); - responseDocument.Included[0].Attributes.Should().NotContainKey("url"); var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -376,8 +376,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["color"].Should().Be(article.ArticleTags.Single().Tag.Color.ToString("G")); - responseDocument.Included[0].Attributes.Should().NotContainKey("name"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -432,19 +432,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(blog.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); - responseDocument.SingleData.Attributes.Should().NotContainKey("companyName"); responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(2); responseDocument.Included[0].Attributes["firstName"].Should().Be(blog.Owner.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Owner.LastName); - responseDocument.Included[0].Attributes.Should().NotContainKey("dateOfBirth"); responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); + responseDocument.Included[1].Attributes.Should().HaveCount(1); responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); - responseDocument.Included[1].Attributes.Should().NotContainKey("url"); var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -504,8 +504,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(blog.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); - responseDocument.SingleData.Attributes.Should().NotContainKey("companyName"); responseDocument.Included.Should().HaveCount(2); @@ -555,8 +555,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Id.Should().Be(article.StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); - responseDocument.ManyData[0].Attributes.Should().NotContainKey("url"); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -603,7 +603,7 @@ public async Task Cannot_select_in_unknown_nested_scope() } [Fact] - public async Task Cannot_select_blocked_attribute() + public async Task Cannot_select_attribute_with_blocked_capability() { // Arrange var user = _userFaker.Generate(); @@ -652,8 +652,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(todoItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["calculatedValue"].Should().Be(todoItem.CalculatedValue); - responseDocument.SingleData.Attributes.Should().NotContainKey("description"); var todoItemCaptured = (TodoItem) store.Resources.Should().ContainSingle(x => x is TodoItem).And.Subject.Single(); todoItemCaptured.CalculatedValue.Should().Be(todoItem.CalculatedValue); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs new file mode 100644 index 0000000000..ac4c0d4c89 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -0,0 +1,707 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +{ + public sealed class CreateResourceTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public CreateResourceTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + options.AllowClientGeneratedIds = false; + } + + [Fact] + public async Task Sets_location_header_for_created_resource() + { + // Arrange + var newWorkItem = _fakers.WorkItem.Generate(); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = newWorkItem.Description + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + var newWorkItemId = responseDocument.SingleData.Id; + httpResponse.Headers.Location.Should().Be("/workItems/" + newWorkItemId); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be("http://localhost" + httpResponse.Headers.Location); + } + + [Fact] + public async Task Can_create_resource_with_int_ID() + { + // Arrange + var newWorkItem = _fakers.WorkItem.Generate(); + newWorkItem.DueAt = null; + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = newWorkItem.Description + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + responseDocument.SingleData.Attributes["dueAt"].Should().Be(newWorkItem.DueAt); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + workItemInDatabase.DueAt.Should().Be(newWorkItem.DueAt); + }); + + var property = typeof(WorkItem).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(int)); + } + + [Fact] + public async Task Can_create_resource_with_long_ID() + { + // Arrange + var newUserAccount = _fakers.UserAccount.Generate(); + + var requestBody = new + { + data = new + { + type = "userAccounts", + attributes = new + { + firstName = newUserAccount.FirstName, + lastName = newUserAccount.LastName + } + } + }; + + var route = "/userAccounts"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("userAccounts"); + responseDocument.SingleData.Attributes["firstName"].Should().Be(newUserAccount.FirstName); + responseDocument.SingleData.Attributes["lastName"].Should().Be(newUserAccount.LastName); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newUserAccountId = long.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var userAccountInDatabase = await dbContext.UserAccounts + .FirstAsync(userAccount => userAccount.Id == newUserAccountId); + + userAccountInDatabase.FirstName.Should().Be(newUserAccount.FirstName); + userAccountInDatabase.LastName.Should().Be(newUserAccount.LastName); + }); + + var property = typeof(UserAccount).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(long)); + } + + [Fact] + public async Task Can_create_resource_with_guid_ID() + { + // Arrange + var newGroup = _fakers.WorkItemGroup.Generate(); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + attributes = new + { + name = newGroup.Name + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == newGroupId); + + groupInDatabase.Name.Should().Be(newGroup.Name); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + } + + [Fact] + public async Task Can_create_resource_without_attributes_or_relationships() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + }, + relationship = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().BeNull(); + responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Description.Should().BeNull(); + workItemInDatabase.DueAt.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_resource_with_unknown_attribute() + { + // Arrange + var newWorkItem = _fakers.WorkItem.Generate(); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + doesNotExist = "ignored", + description = newWorkItem.Description + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + }); + } + + [Fact] + public async Task Can_create_resource_with_unknown_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + doesNotExist = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstOrDefaultAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Should().NotBeNull(); + }); + } + + [Fact] + public async Task Cannot_create_resource_with_client_generated_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C", + attributes = new + { + name = "Black" + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Specifying the resource ID in POST requests is not allowed."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/id"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_request_body() + { + // Arrange + var requestBody = string.Empty; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_type() + { + // Arrange + var requestBody = new + { + data = new + { + attributes = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "doesNotExist", + attributes = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_on_unknown_resource_type_in_url() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + } + } + }; + + var route = "/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_create_on_resource_type_mismatch_between_url_and_body() + { + // Arrange + var requestBody = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C" + } + }; + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource of type 'workItems' in POST request body at endpoint '/workItems', instead of 'rgbColors'."); + } + + [Fact] + public async Task Cannot_create_resource_attribute_with_blocked_capability() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Setting the initial value of the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().StartWith("Setting the initial value of 'concurrencyToken' is not allowed. - Request body:"); + } + + [Fact] + public async Task Cannot_create_resource_with_readonly_attribute() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItemGroups", + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + responseDocument.Errors[0].Detail.Should().StartWith("Attribute 'concurrencyToken' is read-only. - Request body:"); + } + + [Fact] + public async Task Cannot_create_resource_for_broken_JSON_request_body() + { + // Arrange + var requestBody = "{ \"data\" {"; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); + } + + [Fact] + public async Task Cannot_update_resource_with_incompatible_attribute_value() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + dueAt = "not-a-valid-time" + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'Nullable`1'. - Request body: <<"); + } + + [Fact] + public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + var existingTag = _fakers.WorkTags.Generate(); + + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + dbContext.WorkTags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = newDescription + }, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + }, + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTag.StringId + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .Include(workItem => workItem.Subscribers) + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Description.Should().Be(newDescription); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs new file mode 100644 index 0000000000..92c19bbdf6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +{ + public sealed class CreateResourceWithClientGeneratedIdTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects() + { + // Arrange + var newGroup = _fakers.WorkItemGroup.Generate(); + newGroup.Id = Guid.NewGuid(); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = newGroup.StringId, + attributes = new + { + name = newGroup.Name + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Id.Should().Be(newGroup.StringId); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == newGroup.Id); + + groupInDatabase.Name.Should().Be(newGroup.Name); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_with_fieldset() + { + // Arrange + var newGroup = _fakers.WorkItemGroup.Generate(); + newGroup.Id = Guid.NewGuid(); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = newGroup.StringId, + attributes = new + { + name = newGroup.Name + } + } + }; + + var route = "/workItemGroups?fields=name"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Id.Should().Be(newGroup.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == newGroup.Id); + + groupInDatabase.Name.Should().Be(newGroup.Name); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() + { + // Arrange + var newColor = _fakers.RgbColor.Generate(); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = newColor.StringId, + attributes = new + { + displayName = newColor.DisplayName + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorInDatabase = await dbContext.RgbColors + .FirstAsync(color => color.Id == newColor.Id); + + colorInDatabase.DisplayName.Should().Be(newColor.DisplayName); + }); + + var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects_with_fieldset() + { + // Arrange + var newColor = _fakers.RgbColor.Generate(); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = newColor.StringId, + attributes = new + { + displayName = newColor.DisplayName + } + } + }; + + var route = "/rgbColors?fields=id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorInDatabase = await dbContext.RgbColors + .FirstAsync(color => color.Id == newColor.Id); + + colorInDatabase.DisplayName.Should().Be(newColor.DisplayName); + }); + + var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Cannot_create_resource_for_existing_client_generated_ID() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + + var colorToCreate = _fakers.RgbColor.Generate(); + colorToCreate.Id = existingColor.Id; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.Add(existingColor); + + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = colorToCreate.StringId, + attributes = new + { + displayName = colorToCreate.DisplayName + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Another resource with the specified ID already exists."); + responseDocument.Errors[0].Detail.Should().Be($"Another resource of type 'rgbColors' with ID '{existingColor.StringId}' already exists."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs new file mode 100644 index 0000000000..c20ab0ea9d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -0,0 +1,663 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +{ + public sealed class CreateResourceWithToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public CreateResourceWithToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_create_HasMany_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + }, + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.Included.Should().BeNull(); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[1].Id); + }); + } + + [Fact] + public async Task Can_create_HasMany_relationship_with_include() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + }, + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + } + }; + + var route = "/workItems?include=subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.Should().OnlyContain(resource => resource.Type == "userAccounts"); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[0].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["lastName"] != null); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[1].Id); + }); + } + + [Fact] + public async Task Can_create_HasMany_relationship_with_include_and_secondary_fieldset() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + }, + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + } + }; + + var route = "/workItems?include=subscribers&fields[subscribers]=firstName"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.Should().OnlyContain(resource => resource.Type == "userAccounts"); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[0].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[0].Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[1].Id); + }); + } + + [Fact] + public async Task Can_create_HasManyThrough_relationship_with_include_and_fieldsets() + { + // Arrange + var existingTags = _fakers.WorkTags.Generate(3); + var workItemToCreate = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkTags.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = workItemToCreate.Description, + priority = workItemToCreate.Priority + }, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + }, + new + { + type = "workTags", + id = existingTags[2].StringId + } + } + } + } + } + }; + + var route = "/workItems?fields=priority&include=tags&fields[tags]=text"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["priority"].Should().Be(workItemToCreate.Priority.ToString("G")); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(3); + responseDocument.Included.Should().OnlyContain(resource => resource.Type == "workTags"); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[0].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[1].StringId); + responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[2].StringId); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["text"] != null); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.WorkItemTags.Should().HaveCount(3); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[2].Id); + }); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + id = 12345678 + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_IDs() + { + // Arrange + var requestBody = new + { + data = new + { + type = "userAccounts", + relationships = new + { + assignedItems = new + { + data = new[] + { + new + { + type = "workItems", + id = 12345678 + }, + new + { + type = "workItems", + id = 87654321 + } + } + } + } + } + }; + + var route = "/userAccounts"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().StartWith("Related resource of type 'workItems' with ID '12345678' in relationship 'assignedItems' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().StartWith("Related resource of type 'workItems' with ID '87654321' in relationship 'assignedItems' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'subscribers' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + + [Fact] + public async Task Can_create_with_duplicates() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccount.StringId + }, + new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + } + }; + + var route = "/workItems?include=subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Cannot_create_with_null_data_in_HasMany_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + tags = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs new file mode 100644 index 0000000000..40f5be4404 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -0,0 +1,536 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +{ + public sealed class CreateResourceWithToOneRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public CreateResourceWithToOneRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + relationships = new + { + color = new + { + data = new + { + type = "rgbColors", + id = existingGroup.Color.StringId + } + } + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + var newGroupId = responseDocument.SingleData.Id; + newGroupId.Should().NotBeNullOrEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .Include(group => group.Color) + .ToListAsync(); + + var newGroupInDatabase = groupsInDatabase.Single(group => group.StringId == newGroupId); + newGroupInDatabase.Color.Should().NotBeNull(); + newGroupInDatabase.Color.Id.Should().Be(existingGroup.Color.Id); + + var existingGroupInDatabase = groupsInDatabase.Single(group => group.Id == existingGroup.Id); + existingGroupInDatabase.Color.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + existingColor.Group = _fakers.WorkItemGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.Add(existingColor); + await dbContext.SaveChangesAsync(); + }); + + string colorId = "0A0B0C"; + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = colorId, + relationships = new + { + group = new + { + data = new + { + type = "workItemGroups", + id = existingColor.Group.StringId + } + } + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .Include(rgbColor => rgbColor.Group) + .ToListAsync(); + + var newColorInDatabase = colorsInDatabase.Single(color => color.Id == colorId); + newColorInDatabase.Group.Should().NotBeNull(); + newColorInDatabase.Group.Id.Should().Be(existingColor.Group.Id); + + var existingColorInDatabase = colorsInDatabase.Single(color => color.Id == existingColor.Id); + existingColorInDatabase.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_relationship_with_include() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = "/workItems?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Can_create_relationship_with_include_and_primary_fieldset() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + var newWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = newWorkItem.Description, + priority = newWorkItem.Priority + }, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = "/workItems?fields=description&include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + workItemInDatabase.Priority.Should().Be(newWorkItem.Priority); + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_with_unknown_relationship_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = 12345678 + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().StartWith("Related resource of type 'userAccounts' with ID '12345678' in relationship 'assignee' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + + [Fact] + public async Task Can_create_resource_with_duplicate_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + assignee_duplicate = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + }; + + var requestBodyText = JsonConvert.SerializeObject(requestBody).Replace("assignee_duplicate", "assignee"); + + var route = "/workItems?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBodyText); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccounts[1].StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccounts[1].FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccounts[1].LastName); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[1].Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs new file mode 100644 index 0000000000..3e932dfdfd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs @@ -0,0 +1,222 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Deleting +{ + public sealed class DeleteResourceTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public DeleteResourceTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_delete_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .FirstOrDefaultAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemsInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_missing_resource() + { + // Arrange + var route = "/workItems/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + existingColor.Group = _fakers.WorkItemGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.Add(existingColor); + await dbContext.SaveChangesAsync(); + }); + + var route = "/rgbColors/" + existingColor.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .FirstOrDefaultAsync(color => color.Id == existingColor.Id); + + colorsInDatabase.Should().BeNull(); + + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == existingColor.Group.Id); + + groupInDatabase.Color.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_delete_existing_resource_with_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItemGroups/" + existingGroup.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .FirstOrDefaultAsync(group => group.Id == existingGroup.Id); + + groupsInDatabase.Should().BeNull(); + + var colorInDatabase = await dbContext.RgbColors + .FirstAsync(color => color.Id == existingGroup.Color.Id); + + colorInDatabase.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_delete_existing_resource_with_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstOrDefaultAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Should().BeNull(); + + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + + userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_delete_resource_with_HasManyThrough_relationship() + { + // Arrange + var existingWorkItemTag = new WorkItemTag + { + Item = _fakers.WorkItem.Generate(), + Tag = _fakers.WorkTags.Generate() + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItemTags.Add(existingWorkItemTag); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems/" + existingWorkItemTag.Item.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .FirstOrDefaultAsync(workItem => workItem.Id == existingWorkItemTag.Item.Id); + + workItemsInDatabase.Should().BeNull(); + + var workItemTagsInDatabase = await dbContext.WorkItemTags + .FirstOrDefaultAsync(workItemTag => workItemTag.Item.Id == existingWorkItemTag.Item.Id); + + workItemTagsInDatabase.Should().BeNull(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs new file mode 100644 index 0000000000..e6915211a8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs @@ -0,0 +1,16 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class RgbColor : Identifiable + { + [Attr] + public string DisplayName { get; set; } + + // TODO: Change into required relationship and add a test that fails when trying to assign null. + [HasOne] + public WorkItemGroup Group { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs new file mode 100644 index 0000000000..45113fe3e2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class RgbColorsController : JsonApiController + { + public RgbColorsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs new file mode 100644 index 0000000000..9668064291 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -0,0 +1,664 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +{ + public sealed class AddToToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public AddToToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_add_to_HasOne_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Only to-many relationships can be updated through this endpoint."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignee' must be a to-many relationship."); + } + + [Fact] + public async Task Can_add_to_HasMany_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(3); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_add_to_HasManyThrough_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItems = _fakers.WorkItem.Generate(2); + existingWorkItems[0].WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + existingWorkItems[1].WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.AddRange(existingWorkItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItems[0].StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemsInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .ToListAsync(); + + var workItemInDatabase1 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[0].Id); + + workItemInDatabase1.WorkItemTags.Should().HaveCount(2); + workItemInDatabase1.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase1.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.Id); + + var workItemInDatabase2 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[1].Id); + + workItemInDatabase2.WorkItemTags.Should().HaveCount(1); + workItemInDatabase2.WorkItemTags.ElementAt(0).Tag.Id.Should().Be(existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.Id); + }); + } + + [Fact] + public async Task Cannot_add_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_add_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_add_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_add_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + } + + [Fact] + public async Task Cannot_add_unknown_IDs_to_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'userAccounts' with ID '88888888' in relationship 'subscribers' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'userAccounts' with ID '99999999' in relationship 'subscribers' does not exist."); + } + + [Fact] + public async Task Cannot_add_unknown_IDs_to_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = 88888888 + }, + new + { + type = "workTags", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'workTags' with ID '88888888' in relationship 'tags' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'workTags' with ID '99999999' in relationship 'tags' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_add_to_unknown_resource_ID_in_url() + { + // Arrange + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = "/workItems/99999999/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_unknown_relationship_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + + [Fact] + public async Task Cannot_add_for_relationship_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in POST request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + } + + [Fact] + public async Task Can_add_with_duplicates() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_add_with_empty_list() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(0); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs new file mode 100644 index 0000000000..e342229c51 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -0,0 +1,661 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +{ + public sealed class RemoveFromToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public RemoveFromToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_remove_from_HasOne_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Assignee.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Only to-many relationships can be updated through this endpoint."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'assignee' must be a to-many relationship."); + } + + [Fact] + public async Task Can_remove_from_HasMany_relationship_with_unassigned_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + + var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + userAccountsInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Can_remove_from_HasManyThrough_relationship_with_unassigned_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + var existingTag = _fakers.WorkTags.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingWorkItem, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(1).Tag.StringId + }, + new + { + type = "workTags", + id = existingTag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + + var tagsInDatabase = await dbContext.WorkTags.ToListAsync(); + tagsInDatabase.Should().HaveCount(3); + }); + } + + [Fact] + public async Task Cannot_remove_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_remove_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_remove_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_remove_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + } + + [Fact] + public async Task Cannot_remove_unknown_IDs_from_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'userAccounts' with ID '88888888' in relationship 'subscribers' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'userAccounts' with ID '99999999' in relationship 'subscribers' does not exist."); + } + + [Fact] + public async Task Cannot_remove_unknown_IDs_from_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = 88888888 + }, + new + { + type = "workTags", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'workTags' with ID '88888888' in relationship 'tags' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'workTags' with ID '99999999' in relationship 'tags' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_remove_from_unknown_resource_ID_in_url() + { + // Arrange + var existingSubscriber = _fakers.UserAccount.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = "/workItems/99999999/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_unknown_relationship_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + + [Fact] + public async Task Cannot_remove_for_relationship_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in DELETE request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + } + + [Fact] + public async Task Can_remove_with_duplicates() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_remove_with_empty_list() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..ced3effb3d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,726 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +{ + public sealed class ReplaceToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public ReplaceToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_clear_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var existingTags = _fakers.WorkTags.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + dbContext.WorkTags.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(3); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + }); + } + + [Fact] + public async Task Cannot_replace_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_with_unknown_IDs_in_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'userAccounts' with ID '88888888' in relationship 'subscribers' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'userAccounts' with ID '99999999' in relationship 'subscribers' does not exist."); + } + + [Fact] + public async Task Cannot_replace_with_unknown_IDs_in_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = 88888888 + }, + new + { + type = "workTags", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(2); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'workTags' with ID '88888888' in relationship 'tags' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'workTags' with ID '99999999' in relationship 'tags' does not exist."); + } + + [Fact] + public async Task Cannot_replace_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_replace_on_unknown_resource_ID_in_url() + { + // Arrange + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = "/workItems/99999999/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_replace_on_unknown_relationship_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 99999999 + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + + [Fact] + public async Task Cannot_replace_on_relationship_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workTags' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + } + + [Fact] + public async Task Can_replace_with_duplicates() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); + }); + } + + [Fact] + public async Task Cannot_replace_with_null_data_in_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_with_null_data_in_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..8534fc0e5d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -0,0 +1,552 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +{ + public sealed class UpdateToOneRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public UpdateToOneRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_ManyToOne_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Assignee.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.AddRange(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItemGroups/{existingGroup.StringId}/relationships/color"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupInDatabase = await dbContext.Groups + .Include(group => group.Color) + .FirstOrDefaultAsync(group => group.Id == existingGroup.Id); + + groupInDatabase.Color.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + var existingColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingGroup, existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingGroup.StringId + } + }; + + var route = $"/rgbColors/{existingColor.StringId}/relationships/group"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .Include(rgbColor => rgbColor.Group) + .ToListAsync(); + + var colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroup.Color.Id); + colorInDatabase1.Group.Should().BeNull(); + + var colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingColor.Id); + colorInDatabase2.Group.Should().NotBeNull(); + colorInDatabase2.Group.Id.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroups = _fakers.WorkItemGroup.Generate(2); + existingGroups[0].Color = _fakers.RgbColor.Generate(); + existingGroups[1].Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.AddRange(existingGroups); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingGroups[0].Color.StringId + } + }; + + var route = $"/workItemGroups/{existingGroups[1].StringId}/relationships/color"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .Include(group => group.Color) + .ToListAsync(); + + var groupInDatabase1 = groupsInDatabase.Single(group => group.Id == existingGroups[0].Id); + groupInDatabase1.Color.Should().BeNull(); + + var groupInDatabase2 = groupsInDatabase.Single(group => group.Id == existingGroups[1].Id); + groupInDatabase2.Color.Should().NotBeNull(); + groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color.Id); + + var colorsInDatabase = await dbContext.RgbColors + .Include(color => color.Group) + .ToListAsync(); + + var colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroups[0].Color.Id); + colorInDatabase1.Group.Should().NotBeNull(); + colorInDatabase1.Group.Id.Should().Be(existingGroups[1].Id); + + var colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingGroups[1].Color.Id); + colorInDatabase2.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + existingUserAccounts[0].AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + }; + + var route = $"/workItems/{existingUserAccounts[0].AssignedItems.ElementAt(1).StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase2 = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); + + workItemInDatabase2.Assignee.Should().NotBeNull(); + workItemInDatabase2.Assignee.Id.Should().Be(existingUserAccounts[1].Id); + }); + } + + [Fact] + public async Task Cannot_replace_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_create_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + id = 99999999 + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "doesNotExist", + id = 99999999 + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts" + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + } + + [Fact] + public async Task Cannot_create_with_unknown_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = 99999999 + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'userAccounts' with ID '99999999' in relationship 'assignee' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + }; + + var route = $"/doesNotExist/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_create_on_unknown_resource_ID_in_url() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + }; + + var route = "/workItems/99999999/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_unknown_relationship_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = 99999999 + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + + [Fact] + public async Task Cannot_create_on_relationship_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'userAccounts' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/assignee', instead of 'rgbColors'."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..a2b1da6dff --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,838 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +{ + public sealed class ReplaceToManyRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public ReplaceToManyRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new object[0] + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_clear_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new object[0] + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_replace_HasMany_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingSubscriber.Id); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship_with_already_assigned_resources() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var existingTags = _fakers.WorkTags.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + dbContext.WorkTags.AddRange(existingTags); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(3); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingWorkItem.WorkItemTags.ElementAt(0).Tag.Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[0].Id); + workItemInDatabase.WorkItemTags.Should().ContainSingle(workItemTag => workItemTag.Tag.Id == existingTags[1].Id); + }); + } + + [Fact] + public async Task Can_replace_HasMany_relationship_with_include() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?include=subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Can_replace_HasManyThrough_relationship_with_include_and_fieldsets() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingTag = _fakers.WorkTags.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTag.StringId + } + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields=priority&include=tags&fields[tags]=text"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("workTags"); + responseDocument.Included[0].Id.Should().Be(existingTag.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes["text"].Should().Be(existingTag.Text); + + var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == newWorkItemId); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + }); + } + + [Fact] + public async Task Cannot_replace_for_missing_relationship_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + id = 99999999 + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_for_unknown_relationship_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_for_missing_relationship_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts" + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_with_unknown_relationship_IDs() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = 88888888 + }, + new + { + type = "userAccounts", + id = 99999999 + } + } + }, + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = 88888888 + }, + new + { + type = "workTags", + id = 99999999 + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(4); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'userAccounts' with ID '88888888' in relationship 'subscribers' does not exist."); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[1].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[1].Detail.Should().Be("Related resource of type 'userAccounts' with ID '99999999' in relationship 'subscribers' does not exist."); + + responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[2].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[2].Detail.Should().Be("Related resource of type 'workTags' with ID '88888888' in relationship 'tags' does not exist."); + + responseDocument.Errors[3].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[3].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[3].Detail.Should().Be("Related resource of type 'workTags' with ID '99999999' in relationship 'tags' does not exist."); + } + + [Fact] + public async Task Cannot_replace_on_relationship_type_mismatch() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'subscribers' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + + [Fact] + public async Task Can_replace_with_duplicates() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingSubscriber); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Subscribers) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); + }); + } + + [Fact] + public async Task Cannot_replace_with_null_data_in_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'subscribers' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_replace_with_null_data_in_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs new file mode 100644 index 0000000000..640d1d8c27 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -0,0 +1,1079 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +{ + public sealed class UpdateResourceTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public UpdateResourceTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + options.AllowClientGeneratedIds = false; + } + + [Fact] + public async Task Can_update_resource_without_attributes_or_relationships() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + }, + relationships = new + { + } + } + }; + + var route = "/userAccounts/" + existingUserAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_update_resource_with_unknown_attribute() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + var newFirstName = _fakers.UserAccount.Generate().FirstName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + firstName = newFirstName, + doesNotExist = "Ignored" + } + } + }; + + var route = "/userAccounts/" + existingUserAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var userAccountInDatabase = await dbContext.UserAccounts + .FirstAsync(userAccount => userAccount.Id == existingUserAccount.Id); + + userAccountInDatabase.FirstName.Should().Be(newFirstName); + userAccountInDatabase.LastName.Should().Be(existingUserAccount.LastName); + }); + } + + [Fact] + public async Task Can_update_resource_with_unknown_relationship() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + relationships = new + { + doesNotExist = new + { + data = new + { + type = "doesNotExist", + id = 12345678 + } + } + } + } + }; + + var route = "/userAccounts/" + existingUserAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_partially_update_resource_with_guid_ID() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + var newName = _fakers.WorkItemGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingGroup.StringId, + attributes = new + { + name = newName + } + } + }; + + var route = "/workItemGroups/" + existingGroup.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Id.Should().Be(existingGroup.StringId); + responseDocument.SingleData.Attributes["name"].Should().Be(newName); + responseDocument.SingleData.Attributes["isPublic"].Should().Be(existingGroup.IsPublic); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupInDatabase = await dbContext.Groups + .FirstAsync(group => group.Id == existingGroup.Id); + + groupInDatabase.Name.Should().Be(newName); + groupInDatabase.IsPublic.Should().Be(existingGroup.IsPublic); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + } + + [Fact] + public async Task Can_completely_update_resource_with_string_ID() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + var newDisplayName = _fakers.RgbColor.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.Add(existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId, + attributes = new + { + displayName = newDisplayName + } + } + }; + + var route = "/rgbColors/" + existingColor.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorInDatabase = await dbContext.RgbColors + .FirstAsync(color => color.Id == existingColor.Id); + + colorInDatabase.DisplayName.Should().Be(newDisplayName); + }); + + var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Can_update_resource_without_side_effects() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + var newUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + firstName = newUserAccount.FirstName, + lastName = newUserAccount.LastName + } + } + }; + + var route = "/userAccounts/" + existingUserAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var userAccountInDatabase = await dbContext.UserAccounts + .FirstAsync(userAccount => userAccount.Id == existingUserAccount.Id); + + userAccountInDatabase.FirstName.Should().Be(newUserAccount.FirstName); + userAccountInDatabase.LastName.Should().Be(newUserAccount.LastName); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription, + dueAt = (DateTime?)null + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + responseDocument.SingleData.Attributes.Should().ContainKey("concurrencyToken"); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects_with_primary_fieldset() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription, + dueAt = (DateTime?)null + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields=description,priority"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects_with_include_and_fieldsets() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription, + dueAt = (DateTime?)null + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields=description,priority&include=tags&fields[tags]=text"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + + responseDocument.SingleData.Relationships.Should().ContainKey("tags"); + responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(1); + responseDocument.SingleData.Relationships["tags"].ManyData[0].Id.Should().Be(existingWorkItem.WorkItemTags.Single().Tag.StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("workTags"); + responseDocument.Included[0].Id.Should().Be(existingWorkItem.WorkItemTags.Single().Tag.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes["text"].Should().Be(existingWorkItem.WorkItemTags.Single().Tag.Text); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + }); + } + + [Fact] + public async Task Update_resource_with_side_effects_hides_relationship_data_in_response() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Values.Should().OnlyContain(relationshipEntry => relationshipEntry.Data == null); + + responseDocument.Included.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = string.Empty; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "doesNotExist", + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems" + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }; + + var route = "/doesNotExist/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_update_resource_on_unknown_resource_ID_in_url() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + id = 99999999 + } + }; + + var route = "/workItems/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_update_on_resource_type_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workItems' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}', instead of 'rgbColors'."); + } + + [Fact] + public async Task Cannot_update_on_resource_ID_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItems = _fakers.WorkItem.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.AddRange(existingWorkItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItems[0].StringId + } + }; + + var route = "/workItems/" + existingWorkItems[1].StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Resource ID mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource ID '{existingWorkItems[1].StringId}' in PATCH request body at endpoint '/workItems/{existingWorkItems[1].StringId}', instead of '{existingWorkItems[0].StringId}'."); + } + + [Fact] + public async Task Cannot_update_resource_attribute_with_blocked_capability() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().StartWith("Changing the value of 'concurrencyToken' is not allowed. - Request body:"); + } + + [Fact] + public async Task Cannot_update_resource_with_readonly_attribute() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingWorkItem.StringId, + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItemGroups/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + responseDocument.Errors[0].Detail.Should().StartWith("Attribute 'concurrencyToken' is read-only. - Request body:"); + } + + [Fact] + public async Task Cannot_update_resource_for_broken_JSON_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = "{ \"data\" {"; + + var route = "/workItemGroups/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); + } + + [Fact] + public async Task Cannot_change_ID_of_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + id = existingWorkItem.Id + 123456 + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Resource ID is read-only."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_with_incompatible_attribute_value() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + dueAt = "not-a-valid-time" + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'Nullable`1'. - Request body: <<"); + } + + [Fact] + public async Task Can_update_resource_with_attributes_and_multiple_relationship_types() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + existingWorkItem.WorkItemTags = new List + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + var existingUserAccounts = _fakers.UserAccount.Generate(2); + var existingTag = _fakers.WorkTags.Generate(); + + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingTag); + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription + }, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + } + }, + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + }, + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingTag.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .Include(workItem => workItem.Subscribers) + .Include(workItem => workItem.WorkItemTags) + .ThenInclude(workItemTag => workItemTag.Tag) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Description.Should().Be(newDescription); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); + + workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); + + workItemInDatabase.WorkItemTags.Should().HaveCount(1); + workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..f018fd6cbe --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -0,0 +1,661 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +{ + public sealed class UpdateToOneRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public UpdateToOneRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_ManyToOne_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Assignee.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + var existingColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingGroup, existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingGroup.StringId, + relationships = new + { + color = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId + } + } + } + } + }; + + var route = "/workItemGroups/" + existingGroup.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorsInDatabase = await dbContext.RgbColors + .Include(rgbColor => rgbColor.Group) + .ToListAsync(); + + var colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroup.Color.Id); + colorInDatabase1.Group.Should().BeNull(); + + var colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingColor.Id); + colorInDatabase2.Group.Should().NotBeNull(); + colorInDatabase2.Group.Id.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_from_dependent_side() + { + // Arrange + var existingGroups = _fakers.WorkItemGroup.Generate(2); + existingGroups[0].Color = _fakers.RgbColor.Generate(); + existingGroups[1].Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.AddRange(existingGroups); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingGroups[0].Color.StringId, + relationships = new + { + group = new + { + data = new + { + type = "workItemGroups", + id = existingGroups[1].StringId + } + } + } + } + }; + + var route = "/rgbColors/" + existingGroups[0].Color.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var groupsInDatabase = await dbContext.Groups + .Include(group => group.Color) + .ToListAsync(); + + var groupInDatabase1 = groupsInDatabase.Single(group => group.Id == existingGroups[0].Id); + groupInDatabase1.Color.Should().BeNull(); + + var groupInDatabase2 = groupsInDatabase.Single(group => group.Id == existingGroups[1].Id); + groupInDatabase2.Color.Should().NotBeNull(); + groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color.Id); + + var colorsInDatabase = await dbContext.RgbColors + .Include(color => color.Group) + .ToListAsync(); + + var colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroups[0].Color.Id); + colorInDatabase1.Group.Should().NotBeNull(); + colorInDatabase1.Group.Id.Should().Be(existingGroups[1].Id); + + var colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingGroups[1].Color.Id); + colorInDatabase2.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + existingColor.Group = _fakers.WorkItemGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RgbColors.AddRange(existingColor); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId, + relationships = new + { + group = new + { + data = (object)null + } + } + } + }; + + var route = "/rgbColors/" + existingColor.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var colorInDatabase = await dbContext.RgbColors + .Include(color => color.Group) + .FirstOrDefaultAsync(color => color.Id == existingColor.Id); + + colorInDatabase.Group.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + existingUserAccounts[0].AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.AddRange(existingUserAccounts); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingUserAccounts[0].AssignedItems.ElementAt(1).StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + }; + + var route = "/workItems/" + existingUserAccounts[0].AssignedItems.ElementAt(1).StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase2 = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == existingUserAccounts[0].AssignedItems.ElementAt(1).Id); + + workItemInDatabase2.Assignee.Should().NotBeNull(); + workItemInDatabase2.Assignee.Id.Should().Be(existingUserAccounts[1].Id); + }); + } + + [Fact] + public async Task Can_create_relationship_with_include() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Can_replace_relationship_with_include_and_fieldsets() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields=description&include=assignee&fields[assignee]=lastName"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("userAccounts"); + responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Assignee) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); + }); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + id = 99999999 + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts" + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_with_unknown_relationship_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "userAccounts", + id = 99999999 + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'userAccounts' with ID '99999999' in relationship 'assignee' does not exist."); + } + + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C" + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs new file mode 100644 index 0000000000..1e4b60d612 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class UserAccount : Identifiable + { + [Attr] + public string FirstName { get; set; } + + [Attr] + public string LastName { get; set; } + + [HasMany] + public ISet AssignedItems { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs new file mode 100644 index 0000000000..0e2ff633b7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class UserAccountsController : JsonApiController + { + public UserAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs new file mode 100644 index 0000000000..aacfd8613a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class WorkItem : Identifiable + { + [Attr] + public string Description { get; set; } + + [Attr] + public DateTimeOffset? DueAt { get; set; } + + [Attr] + public WorkItemPriority Priority { get; set; } + + [NotMapped] + [Attr(Capabilities = ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] + public Guid ConcurrencyToken { get; set; } = Guid.NewGuid(); + + [HasOne] + public UserAccount Assignee { get; set; } + + [HasMany] + public ISet Subscribers { get; set; } + + [NotMapped] + [HasManyThrough(nameof(WorkItemTags))] + public ISet Tags { get; set; } + public ICollection WorkItemTags { get; set; } + + [HasOne] + public WorkItemGroup Group { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs new file mode 100644 index 0000000000..37dc8cd78c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class WorkItemGroup : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public bool IsPublic { get; set; } + + [NotMapped] + [Attr] + public Guid ConcurrencyToken { get; } = Guid.NewGuid(); + + [HasOne] + public RgbColor Color { get; set; } + + [HasMany] + public IList Items { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs new file mode 100644 index 0000000000..fffef616d5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class WorkItemGroupsController : JsonApiController + { + public WorkItemGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs new file mode 100644 index 0000000000..31d639dbb7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public enum WorkItemPriority + { + Low, + Medium, + High + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs new file mode 100644 index 0000000000..4efad89a0b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class WorkItemTag + { + public WorkItem Item { get; set; } + public int ItemId { get; set; } + + public WorkTag Tag { get; set; } + public int TagId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs new file mode 100644 index 0000000000..2bfc3c3c42 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class WorkItemsController : JsonApiController + { + public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs new file mode 100644 index 0000000000..8fe6a903b3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class WorkTag : Identifiable + { + [Attr] + public string Text { get; set; } + + [Attr] + public bool IsBuiltIn { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs new file mode 100644 index 0000000000..ab63f54368 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class WriteDbContext : DbContext + { + public DbSet WorkItems { get; set; } + public DbSet WorkTags { get; set; } + public DbSet WorkItemTags { get; set; } + public DbSet Groups { get; set; } + public DbSet RgbColors { get; set; } + public DbSet UserAccounts { get; set; } + + public WriteDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(workItem => workItem.Assignee) + .WithMany(userAccount => userAccount.AssignedItems); + + builder.Entity() + .HasMany(workItem => workItem.Subscribers) + .WithOne(); + + builder.Entity() + .HasOne(workItemGroup => workItemGroup.Color) + .WithOne(color => color.Group) + .HasForeignKey(); + + builder.Entity() + .HasKey(workItemTag => new { workItemTag.ItemId, workItemTag.TagId}); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs new file mode 100644 index 0000000000..a5be6362ae --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs @@ -0,0 +1,111 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using Bogus; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + internal class WriteFakers + { + private readonly Lazy> _lazyWorkItemFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(workItem => workItem.Description, f => f.Lorem.Sentence()) + .RuleFor(workItem => workItem.DueAt, f => f.Date.Future()) + .RuleFor(workItem => workItem.Priority, f => f.PickRandom())); + + private readonly Lazy> _lazyWorkTagsFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(workTag => workTag.Text, f => f.Lorem.Word()) + .RuleFor(workTag => workTag.IsBuiltIn, f => f.Random.Bool())); + + private readonly Lazy> _lazyUserAccountFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(userAccount => userAccount.FirstName, f => f.Name.FirstName()) + .RuleFor(userAccount => userAccount.LastName, f => f.Name.LastName())); + + private readonly Lazy> _lazyWorkItemGroupFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(group => group.Name, f => f.Lorem.Word()) + .RuleFor(group => group.IsPublic, f => f.Random.Bool())); + + private readonly Lazy> _lazyRgbColorFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(color => color.Id, f => f.Random.Hexadecimal(6)) + .RuleFor(color => color.DisplayName, f => f.Lorem.Word())); + + public Faker WorkItem => _lazyWorkItemFaker.Value; + public Faker WorkTags => _lazyWorkTagsFaker.Value; + public Faker UserAccount => _lazyUserAccountFaker.Value; + public Faker WorkItemGroup => _lazyWorkItemGroupFaker.Value; + public Faker RgbColor => _lazyRgbColorFaker.Value; + + private static int GetFakerSeed() + { + // The goal here is to have stable data over multiple test runs, but at the same time different data per test case. + + MethodBase testMethod = GetTestMethod(); + var testName = testMethod.DeclaringType?.FullName + "." + testMethod.Name; + + return GetDeterministicHashCode(testName); + } + + private static MethodBase GetTestMethod() + { + var stackTrace = new StackTrace(); + + var testMethod = stackTrace.GetFrames() + .Select(stackFrame => stackFrame?.GetMethod()) + .FirstOrDefault(IsTestMethod); + + if (testMethod == null) + { + // If called after the first await statement, the test method is no longer on the stack, + // but has been replaced with the compiler-generated async/wait state machine. + throw new InvalidOperationException("Fakers can only be used from within (the start of) a test method."); + } + + return testMethod; + } + + private static bool IsTestMethod(MethodBase method) + { + if (method == null) + { + return false; + } + + return method.GetCustomAttribute(typeof(FactAttribute)) != null || method.GetCustomAttribute(typeof(TheoryAttribute)) != null; + } + + private static int GetDeterministicHashCode(string source) + { + // https://andrewlock.net/why-is-string-gethashcode-different-each-time-i-run-my-program-in-net-core/ + unchecked + { + int hash1 = (5381 << 16) + 5381; + int hash2 = hash1; + + for (int i = 0; i < source.Length; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ source[i]; + + if (i == source.Length - 1) + { + break; + } + + hash2 = ((hash2 << 5) + hash2) ^ source[i + 1]; + } + + return hash1 + hash2 * 1566083941; + } + } + } +} diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index 403f51c2e7..4dffd3ee27 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -66,7 +66,7 @@ private static void AssertStatusCode(HttpStatusCode expected, HttpResponseMessag if (expected != response.StatusCode) { var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(false, $"Got {response.StatusCode} status code instead of {expected}. Payload: {responseBody}"); + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); } } } diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index decaf0e837..f118483b76 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -144,9 +144,7 @@ await ExecuteOnDbContextAsync(async dbContext => AssertStatusCode(HttpStatusCode.NoContent, response); string responseBody = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseBody); - - Assert.Null(document); + Assert.Empty(responseBody); } private async Task ExecuteOnDbContextAsync(Func asyncAction) @@ -162,7 +160,7 @@ private static void AssertStatusCode(HttpStatusCode expected, HttpResponseMessag if (expected != response.StatusCode) { var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(false, $"Got {response.StatusCode} status code instead of {expected}. Payload: {responseBody}"); + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); } } } diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 10d71866b9..57e41f7e96 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -40,12 +40,15 @@ public ResourceController( IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, ICreateService create = null, + IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, - IDeleteService delete = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, - update, updateRelationships, delete) - { } + ISetRelationshipService setRelationship = null, + IDeleteService delete = null, + IRemoveFromRelationshipService removeFromRelationship = null) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, + update, setRelationship, delete, removeFromRelationship) + { + } } [Fact] @@ -188,10 +191,11 @@ public async Task PatchAsync_Throws_405_If_No_Service() { // Arrange const int id = 0; + var resource = new Resource(); var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); + var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, resource)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); @@ -221,14 +225,14 @@ public async Task PatchRelationshipsAsync_Calls_Service() { // Arrange const int id = 0; - var serviceMock = new Mock>(); - var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, updateRelationships: serviceMock.Object); + var serviceMock = new Mock>(); + var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, setRelationship: serviceMock.Object); // Act await controller.PatchRelationshipAsync(id, string.Empty, null); // Assert - serviceMock.Verify(m => m.UpdateRelationshipAsync(id, string.Empty, null), Times.Once); + serviceMock.Verify(m => m.SetRelationshipAsync(id, string.Empty, null), Times.Once); } [Fact] diff --git a/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs b/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs index 215ac7ab8c..9fbe8275f3 100644 --- a/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs +++ b/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs @@ -23,7 +23,7 @@ public void Errors_Correctly_Infers_Status_Code() { new Error(HttpStatusCode.OK) {Title = "weird"}, new Error(HttpStatusCode.BadRequest) {Title = "bad"}, - new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"}, + new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"} }; var errors500 = new List @@ -32,7 +32,7 @@ public void Errors_Correctly_Infers_Status_Code() new Error(HttpStatusCode.BadRequest) {Title = "bad"}, new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"}, new Error(HttpStatusCode.InternalServerError) {Title = "really bad"}, - new Error(HttpStatusCode.BadGateway) {Title = "really bad specific"}, + new Error(HttpStatusCode.BadGateway) {Title = "really bad specific"} }; // Act diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index dc3931d429..481f5b5026 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -54,7 +54,6 @@ public void AddJsonApiInternals_Adds_All_Required_Services() Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); - Assert.NotNull(provider.GetService(typeof(RepositoryRelationshipUpdateHelper))); } [Fact] @@ -165,9 +164,11 @@ private class IntResourceService : IResourceService public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(int id) => throw new NotImplementedException(); public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task UpdateAsync(int id, IntResource requestResource) => throw new NotImplementedException(); - public Task UpdateRelationshipAsync(int id, string relationshipName, object relationships) => throw new NotImplementedException(); + public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); + public Task UpdateAsync(int id, IntResource resource) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -177,9 +178,11 @@ private class GuidResourceService : IResourceService public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(Guid id) => throw new NotImplementedException(); public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task UpdateAsync(Guid id, GuidResource requestResource) => throw new NotImplementedException(); - public Task UpdateRelationshipAsync(Guid id, string relationshipName, object relationships) => throw new NotImplementedException(); + public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); + public Task UpdateAsync(Guid id, GuidResource resource) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } diff --git a/test/UnitTests/Internal/TypeHelper_Tests.cs b/test/UnitTests/Internal/TypeHelper_Tests.cs index 7185641091..55a539ea44 100644 --- a/test/UnitTests/Internal/TypeHelper_Tests.cs +++ b/test/UnitTests/Internal/TypeHelper_Tests.cs @@ -95,7 +95,7 @@ public void ConvertType_Returns_Default_Value_For_Empty_Strings() { typeof(short), (short)0 }, { typeof(long), (long)0 }, { typeof(string), "" }, - { typeof(Guid), Guid.Empty }, + { typeof(Guid), Guid.Empty } }; foreach (var t in data) diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index fb0cacc5b5..6fca6fc3ec 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel.Design; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCoreExample.Data; @@ -15,12 +16,15 @@ namespace UnitTests.Models { public sealed class ResourceConstructionTests { + public Mock _requestMock; public Mock _mockHttpContextAccessor; - + public ResourceConstructionTests() { _mockHttpContextAccessor = new Mock(); _mockHttpContextAccessor.Setup(mock => mock.HttpContext).Returns(new DefaultHttpContext()); + _requestMock = new Mock(); + _requestMock.Setup(mock => mock.Kind).Returns(EndpointKind.Primary); } [Fact] @@ -31,7 +35,7 @@ public void When_resource_has_default_constructor_it_must_succeed() .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); var body = new { @@ -60,7 +64,7 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); var body = new { @@ -96,7 +100,7 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ var serviceContainer = new ServiceContainer(); serviceContainer.AddService(typeof(AppDbContext), appDbContext); - var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); var body = new { @@ -126,7 +130,7 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() .Add() .Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); var body = new { diff --git a/test/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/UnitTests/QueryStringParameters/FilterParseTests.cs index dc8b74411b..3f1f272066 100644 --- a/test/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -80,6 +80,7 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, [InlineData("filter", "any(null,'a','b')", "Attribute 'null' does not exist on resource 'blogs'.")] [InlineData("filter", "any('a','b','c')", "Field name expected.")] [InlineData("filter", "any(title,'b','c',)", "Value between quotes expected.")] + [InlineData("filter", "any(title,'b')", ", expected.")] [InlineData("filter[articles]", "any(author,'a','b')", "Attribute 'author' does not exist on resource 'articles'.")] [InlineData("filter", "and(", "Filter function expected.")] [InlineData("filter", "or(equals(title,'some'),equals(title,'other')", ") expected.")] diff --git a/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs b/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs index 9873f7f4fb..f431a8a604 100644 --- a/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs +++ b/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs @@ -69,7 +69,7 @@ public RelationshipDictionaryTests() [Fact] public void RelationshipsDictionary_GetByRelationships() { - // Arrange + // Arrange RelationshipsDictionary relationshipsDictionary = new RelationshipsDictionary(Relationships); // Act @@ -84,7 +84,7 @@ public void RelationshipsDictionary_GetByRelationships() [Fact] public void RelationshipsDictionary_GetAffected() { - // Arrange + // Arrange RelationshipsDictionary relationshipsDictionary = new RelationshipsDictionary(Relationships); // Act @@ -101,7 +101,7 @@ public void RelationshipsDictionary_GetAffected() [Fact] public void ResourceHashSet_GetByRelationships() { - // Arrange + // Arrange ResourceHashSet resources = new ResourceHashSet(AllResources, Relationships); // Act @@ -122,7 +122,7 @@ public void ResourceHashSet_GetByRelationships() [Fact] public void ResourceDiff_GetByRelationships() { - // Arrange + // Arrange var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id }).ToList()); DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); @@ -155,7 +155,7 @@ public void ResourceDiff_GetByRelationships() [Fact] public void ResourceDiff_Loops_Over_Diffs() { - // Arrange + // Arrange var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); @@ -172,7 +172,7 @@ public void ResourceDiff_Loops_Over_Diffs() [Fact] public void ResourceDiff_GetAffected_Relationships() { - // Arrange + // Arrange var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); @@ -190,7 +190,7 @@ public void ResourceDiff_GetAffected_Relationships() [Fact] public void ResourceDiff_GetAffected_Attributes() { - // Arrange + // Arrange var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); var updatedAttributes = new Dictionary> { diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs index a465f7dce6..30d391a394 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs @@ -8,14 +8,14 @@ namespace UnitTests.ResourceHooks { public sealed class AfterCreateTests : HooksTestsSetup { - private readonly ResourceHook[] targetHooks = { ResourceHook.AfterCreate, ResourceHook.AfterUpdateRelationship }; + private readonly ResourceHook[] _targetHooks = { ResourceHook.AfterCreate, ResourceHook.AfterUpdateRelationship }; [Fact] public void AfterCreate() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, DisableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, DisableDbValues); var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); var todoList = CreateTodoWithOwner(); @@ -33,7 +33,7 @@ public void AfterCreate_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, DisableDbValues); var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); var todoList = CreateTodoWithOwner(); @@ -49,7 +49,7 @@ public void AfterCreate_Without_Parent_Hook_Implemented() public void AfterCreate_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, DisableDbValues); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); var todoList = CreateTodoWithOwner(); diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs index 2efe5e1391..b8ad10bd11 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs @@ -11,56 +11,59 @@ namespace UnitTests.ResourceHooks { public sealed class BeforeCreate_WithDbValues_Tests : HooksTestsSetup { - private readonly ResourceHook[] targetHooks = { ResourceHook.BeforeCreate, ResourceHook.BeforeImplicitUpdateRelationship, ResourceHook.BeforeUpdateRelationship }; - private readonly ResourceHook[] targetHooksNoImplicit = { ResourceHook.BeforeCreate, ResourceHook.BeforeUpdateRelationship }; + private readonly ResourceHook[] _targetHooks = { ResourceHook.BeforeCreate, ResourceHook.BeforeImplicitUpdateRelationship, ResourceHook.BeforeUpdateRelationship }; + private readonly ResourceHook[] _targetHooksNoImplicit = { ResourceHook.BeforeCreate, ResourceHook.BeforeUpdateRelationship }; - private readonly string description = "DESCRIPTION"; - private readonly string lastName = "NAME"; - private readonly string personId; - private readonly List todoList; - private readonly DbContextOptions options; + private const string _description = "DESCRIPTION"; + private const string _lastName = "NAME"; + private readonly string _personId; + private readonly List _todoList; + private readonly DbContextOptions _options; public BeforeCreate_WithDbValues_Tests() { - todoList = CreateTodoWithToOnePerson(); + _todoList = CreateTodoWithToOnePerson(); - todoList[0].Id = 0; - todoList[0].Description = description; - var _personId = todoList[0].OneToOnePerson.Id; - personId = _personId.ToString(); + _todoList[0].Id = 0; + _todoList[0].Description = _description; + var person = _todoList[0].OneToOnePerson; + person.LastName = _lastName; + _personId = person.Id.ToString(); var implicitTodo = _todoFaker.Generate(); implicitTodo.Id += 1000; - implicitTodo.OneToOnePersonId = _personId; - implicitTodo.Description = description + description; + implicitTodo.OneToOnePerson = person; + implicitTodo.Description = _description + _description; - options = InitInMemoryDb(context => + _options = InitInMemoryDb(context => { - context.Set().Add(new Person { Id = _personId, LastName = lastName }); + context.Set().Add(person); context.Set().Add(implicitTodo); context.SaveChanges(); }); + + _todoList[0].OneToOnePerson = person; } [Fact] public void BeforeCreate() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, _description)), ResourcePipeline.Post), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Post), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => TodoCheckRelationships(rh, description + description)), + It.Is>(rh => TodoCheckRelationships(rh, _description + _description)), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -71,15 +74,15 @@ public void BeforeCreate_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Post), Times.Once()); @@ -90,17 +93,17 @@ public void BeforeCreate_Without_Parent_Hook_Implemented() public void BeforeCreate_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, _description)), ResourcePipeline.Post), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => TodoCheckRelationships(rh, description + description)), + It.Is>(rh => TodoCheckRelationships(rh, _description + _description)), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -110,17 +113,17 @@ public void BeforeCreate_Without_Child_Hook_Implemented() public void BeforeCreate_NoImplicit() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdate); - var personDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdate); + var personDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, _description)), ResourcePipeline.Post), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Post), Times.Once()); @@ -132,15 +135,15 @@ public void BeforeCreate_NoImplicit_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var personDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Post), Times.Once()); @@ -151,15 +154,15 @@ public void BeforeCreate_NoImplicit_Without_Parent_Hook_Implemented() public void BeforeCreate_NoImplicit_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdate); + var todoDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdate); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); + hookExecutor.BeforeCreate(_todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, _description)), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs index 46000c9318..83f7f21dbe 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs @@ -11,34 +11,32 @@ namespace UnitTests.ResourceHooks { public sealed class BeforeUpdate_WithDbValues_Tests : HooksTestsSetup { - private readonly ResourceHook[] targetHooks = { ResourceHook.BeforeUpdate, ResourceHook.BeforeImplicitUpdateRelationship, ResourceHook.BeforeUpdateRelationship }; - private readonly ResourceHook[] targetHooksNoImplicit = { ResourceHook.BeforeUpdate, ResourceHook.BeforeUpdateRelationship }; + private readonly ResourceHook[] _targetHooks = { ResourceHook.BeforeUpdate, ResourceHook.BeforeImplicitUpdateRelationship, ResourceHook.BeforeUpdateRelationship }; + private readonly ResourceHook[] _targetHooksNoImplicit = { ResourceHook.BeforeUpdate, ResourceHook.BeforeUpdateRelationship }; - private readonly string description = "DESCRIPTION"; - private readonly string lastName = "NAME"; - private readonly string personId; - private readonly List todoList; - private readonly DbContextOptions options; + private const string _description = "DESCRIPTION"; + private const string _lastName = "NAME"; + private readonly string _personId; + private readonly List _todoList; + private readonly DbContextOptions _options; public BeforeUpdate_WithDbValues_Tests() { - todoList = CreateTodoWithToOnePerson(); + _todoList = CreateTodoWithToOnePerson(); - var todoId = todoList[0].Id; - var _personId = todoList[0].OneToOnePerson.Id; - personId = _personId.ToString(); - var _implicitPersonId = _personId + 10000; + var todoId = _todoList[0].Id; + var personId = _todoList[0].OneToOnePerson.Id; + _personId = personId.ToString(); + var implicitPersonId = personId + 10000; var implicitTodo = _todoFaker.Generate(); implicitTodo.Id += 1000; - implicitTodo.OneToOnePersonId = _personId; - implicitTodo.Description = description + description; + implicitTodo.OneToOnePerson = new Person {Id = personId, LastName = _lastName}; + implicitTodo.Description = _description + _description; - options = InitInMemoryDb(context => + _options = InitInMemoryDb(context => { - context.Set().Add(new Person { Id = _personId, LastName = lastName }); - context.Set().Add(new Person { Id = _implicitPersonId, LastName = lastName + lastName }); - context.Set().Add(new TodoItem { Id = todoId, OneToOnePersonId = _implicitPersonId, Description = description }); + context.Set().Add(new TodoItem {Id = todoId, OneToOnePerson = new Person {Id = implicitPersonId, LastName = _lastName + _lastName}, Description = _description}); context.Set().Add(implicitTodo); context.SaveChanges(); }); @@ -48,26 +46,26 @@ public BeforeUpdate_WithDbValues_Tests() public void BeforeUpdate() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), - It.Is>(rh => PersonCheck(lastName, rh)), + It.Is>(ids => PersonIdCheck(ids, _personId)), + It.Is>(rh => PersonCheck(_lastName, rh)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => PersonCheck(lastName + lastName, rh)), + It.Is>(rh => PersonCheck(_lastName + _lastName, rh)), ResourcePipeline.Patch), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => TodoCheck(rh, description + description)), + It.Is>(rh => TodoCheck(rh, _description + _description)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -78,20 +76,20 @@ public void BeforeUpdate() public void BeforeUpdate_Deleting_Relationship() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, ufMock, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, ufMock, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); - ufMock.Setup(c => c.Relationships).Returns(_resourceGraph.GetRelationships((TodoItem t) => t.OneToOnePerson).ToList()); + ufMock.Setup(c => c.Relationships).Returns(_resourceGraph.GetRelationships((TodoItem t) => t.OneToOnePerson).ToHashSet); // Act - var _todoList = new List { new TodoItem { Id = todoList[0].Id } }; - hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); + var todoList = new List { new TodoItem { Id = _todoList[0].Id } }; + hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => PersonCheck(lastName + lastName, rh)), + It.Is>(rh => PersonCheck(_lastName + _lastName, rh)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -103,20 +101,20 @@ public void BeforeUpdate_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var personDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), - It.Is>(rh => PersonCheck(lastName, rh)), + It.Is>(ids => PersonIdCheck(ids, _personId)), + It.Is>(rh => PersonCheck(_lastName, rh)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => PersonCheck(lastName + lastName, rh)), + It.Is>(rh => PersonCheck(_lastName + _lastName, rh)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -126,17 +124,17 @@ public void BeforeUpdate_Without_Parent_Hook_Implemented() public void BeforeUpdate_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); + var todoDiscovery = SetDiscoverableHooks(_targetHooks, EnableDbValues); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship( - It.Is>(rh => TodoCheck(rh, description + description)), + It.Is>(rh => TodoCheck(rh, _description + _description)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -146,17 +144,17 @@ public void BeforeUpdate_Without_Child_Hook_Implemented() public void BeforeUpdate_NoImplicit() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdate); - var personDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var todoDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdate); + var personDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), + It.Is>(ids => PersonIdCheck(ids, _personId)), It.IsAny>(), ResourcePipeline.Patch), Times.Once()); @@ -168,16 +166,16 @@ public void BeforeUpdate_NoImplicit_Without_Parent_Hook_Implemented() { // Arrange var todoDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var personDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var personDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdateRelationship); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship( - It.Is>(ids => PersonIdCheck(ids, personId)), - It.Is>(rh => PersonCheck(lastName, rh)), + It.Is>(ids => PersonIdCheck(ids, _personId)), + It.Is>(rh => PersonCheck(_lastName, rh)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); @@ -187,15 +185,15 @@ public void BeforeUpdate_NoImplicit_Without_Parent_Hook_Implemented() public void BeforeUpdate_NoImplicit_Without_Child_Hook_Implemented() { // Arrange - var todoDiscovery = SetDiscoverableHooks(targetHooksNoImplicit, ResourceHook.BeforeUpdate); + var todoDiscovery = SetDiscoverableHooks(_targetHooksNoImplicit, ResourceHook.BeforeUpdate); var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: _options); // Act - hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); + hookExecutor.BeforeUpdate(_todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, description)), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.Is>((diff) => TodoCheckDiff(diff, _description)), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 138c1f61df..cfa5339a02 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -13,6 +13,7 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; @@ -172,8 +173,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var resourceFactory = new Mock().Object; - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph); return (constraintsMock, hookExecutor, primaryResource); } @@ -206,8 +206,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var resourceFactory = new Mock().Object; - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph); return (constraintsMock, ufMock, hookExecutor, primaryResource, secondaryResource); } @@ -245,8 +244,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var resourceFactory = new Mock().Object; - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph); return (constraintsMock, hookExecutor, primaryResource, firstSecondaryResource, secondSecondaryResource); } @@ -370,9 +368,16 @@ private IResourceReadRepository CreateTestRepository(AppDbC var serviceProvider = ((IInfrastructure) dbContext).Instance; var resourceFactory = new ResourceFactory(serviceProvider); IDbContextResolver resolver = CreateTestDbResolver(dbContext); - var serviceFactory = new Mock().Object; var targetedFields = new TargetedFields(); - return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, serviceFactory, resourceFactory, new List(), NullLoggerFactory.Instance); + var getResourcesByIds = new Mock().Object; + return new EntityFrameworkCoreRepository( + targetedFields, + resolver, + resourceGraph, + resourceFactory, + new List(), + getResourcesByIds, + NullLoggerFactory.Instance); } private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) where TModel : class, IIdentifiable @@ -385,7 +390,7 @@ private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) private void ResolveInverseRelationships(AppDbContext context) { var dbContextResolvers = new[] {new DbContextResolver(context)}; - var inverseRelationships = new InverseRelationships(_resourceGraph, dbContextResolvers); + var inverseRelationships = new InverseRelationshipResolver(_resourceGraph, dbContextResolvers); inverseRelationships.Resolve(); } diff --git a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs index 896b47b115..bfe862d0ca 100644 --- a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs +++ b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs @@ -262,7 +262,7 @@ public void DeserializeSingle_DeeplyNestedIncluded_CanDeserialize() Type = "oneToManyPrincipals", Id = "10", Attributes = new Dictionary { {"attributeMember", deeplyNestedIncludedAttributeValue } } - }, + } }; var body = JsonConvert.SerializeObject(content); @@ -313,7 +313,7 @@ public void DeserializeList_DeeplyNestedIncluded_CanDeserialize() Type = "oneToManyPrincipals", Id = "10", Attributes = new Dictionary { {"attributeMember", deeplyNestedIncludedAttributeValue } } - }, + } }; var body = JsonConvert.SerializeObject(content); @@ -361,7 +361,7 @@ public void DeserializeSingle_ResourceWithInheritanceAndInclusions_CanDeserializ Type = "firstDerivedModels", Id = "20", Attributes = new Dictionary { { "firstProperty", "true" } } - }, + } }; var body = JsonConvert.SerializeObject(content); diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index 09e10ab8c3..2d02c60c51 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -1,9 +1,9 @@ using System; -using System.Collections; using System.Collections.Generic; using System.ComponentModel.Design; using System.Linq; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; using UnitTests.TestModels; @@ -29,7 +29,7 @@ public void DeserializeResourceIdentifiers_SingleData_CanDeserialize() Data = new ResourceObject { Type = "testResource", - Id = "1", + Id = "1" } }; var body = JsonConvert.SerializeObject(content); @@ -66,14 +66,14 @@ public void DeserializeResourceIdentifiers_ArrayData_CanDeserialize() new ResourceObject { Type = "testResource", - Id = "1", + Id = "1" } } }; var body = JsonConvert.SerializeObject(content); // Act - var result = (IIdentifiable[])_deserializer.Deserialize(body); + var result = (IEnumerable)_deserializer.Deserialize(body); // Assert Assert.Equal("1", result.First().StringId); @@ -86,7 +86,7 @@ public void DeserializeResourceIdentifiers_EmptyArrayData_CanDeserialize() var body = JsonConvert.SerializeObject(content); // Act - var result = (IList)_deserializer.Deserialize(body); + var result = (IEnumerable)_deserializer.Deserialize(body); // Assert Assert.Empty(result); @@ -216,6 +216,30 @@ public void DeserializeAttributes_ComplexListType_CanDeserialize() Assert.Equal("testName", result.ComplexFields[0].CompoundName); } + [Fact] + public void DeserializeRelationship_SingleDataForToOneRelationship_CannotDeserialize() + { + // Arrange + var content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents"); + content.SingleData.Relationships["dependents"] = new RelationshipEntry { Data = new ResourceIdentifierObject { Type = "Dependents", Id = "1" } }; + var body = JsonConvert.SerializeObject(content); + + // Act, assert + Assert.Throws(() => _deserializer.Deserialize(body)); + } + + [Fact] + public void DeserializeRelationship_ManyDataForToManyRelationship_CannotDeserialize() + { + // Arrange + var content = CreateDocumentWithRelationships("oneToOnePrincipals", "dependent"); + content.SingleData.Relationships["dependent"] = new RelationshipEntry { Data = new List { new ResourceIdentifierObject { Type = "Dependent", Id = "1" } }}; + var body = JsonConvert.SerializeObject(content); + + // Act, assert + Assert.Throws(() => _deserializer.Deserialize(body)); + } + [Fact] public void DeserializeRelationships_EmptyOneToOneDependent_NavigationPropertyIsNull() { @@ -249,7 +273,7 @@ public void DeserializeRelationships_PopulatedOneToOneDependent_NavigationProper } [Fact] - public void DeserializeRelationships_EmptyOneToOnePrincipal_NavigationPropertyAndForeignKeyAreNull() + public void DeserializeRelationships_EmptyOneToOnePrincipal_NavigationIsNull() { // Arrange var content = CreateDocumentWithRelationships("oneToOneDependents", "principal"); @@ -261,22 +285,25 @@ public void DeserializeRelationships_EmptyOneToOnePrincipal_NavigationPropertyAn // Assert Assert.Equal(1, result.Id); Assert.Null(result.Principal); - Assert.Null(result.PrincipalId); } [Fact] - public void DeserializeRelationships_EmptyRequiredOneToOnePrincipal_ThrowsFormatException() + public void DeserializeRelationships_EmptyRequiredOneToOnePrincipal_NavigationIsNull() { // Arrange var content = CreateDocumentWithRelationships("oneToOneRequiredDependents", "principal"); var body = JsonConvert.SerializeObject(content); - // Act, assert - Assert.Throws(() => _deserializer.Deserialize(body)); + // Act + var result = (OneToOneRequiredDependent) _deserializer.Deserialize(body); + + // assert + Assert.Equal(1, result.Id); + Assert.Null(result.Principal); } [Fact] - public void DeserializeRelationships_PopulatedOneToOnePrincipal_NavigationPropertyAndForeignKeyArePopulated() + public void DeserializeRelationships_PopulatedOneToOnePrincipal_NavigationIsPopulated() { // Arrange var content = CreateDocumentWithRelationships("oneToOneDependents", "principal", "oneToOnePrincipals"); @@ -289,12 +316,11 @@ public void DeserializeRelationships_PopulatedOneToOnePrincipal_NavigationProper Assert.Equal(1, result.Id); Assert.NotNull(result.Principal); Assert.Equal(10, result.Principal.Id); - Assert.Equal(10, result.PrincipalId); Assert.Null(result.AttributeMember); } [Fact] - public void DeserializeRelationships_EmptyOneToManyPrincipal_NavigationAndForeignKeyAreNull() + public void DeserializeRelationships_EmptyOneToManyPrincipal_NavigationIsNull() { // Arrange var content = CreateDocumentWithRelationships("oneToManyDependents", "principal"); @@ -306,23 +332,27 @@ public void DeserializeRelationships_EmptyOneToManyPrincipal_NavigationAndForeig // Assert Assert.Equal(1, result.Id); Assert.Null(result.Principal); - Assert.Null(result.PrincipalId); Assert.Null(result.AttributeMember); } [Fact] - public void DeserializeRelationships_EmptyOneToManyRequiredPrincipal_ThrowsFormatException() + public void DeserializeRelationships_EmptyOneToManyRequiredPrincipal_NavigationIsNull() { // Arrange var content = CreateDocumentWithRelationships("oneToMany-requiredDependents", "principal"); var body = JsonConvert.SerializeObject(content); - // Act, assert - Assert.Throws(() => _deserializer.Deserialize(body)); + // Act + var result = (OneToManyRequiredDependent) _deserializer.Deserialize(body); + + // assert + Assert.Equal(1, result.Id); + Assert.Null(result.Principal); + Assert.Null(result.AttributeMember); } [Fact] - public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationAndForeignKeyArePopulated() + public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationIsPopulated() { // Arrange var content = CreateDocumentWithRelationships("oneToManyDependents", "principal", "oneToManyPrincipals"); @@ -335,7 +365,6 @@ public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationAndFo Assert.Equal(1, result.Id); Assert.NotNull(result.Principal); Assert.Equal(10, result.Principal.Id); - Assert.Equal(10, result.PrincipalId); Assert.Null(result.AttributeMember); } @@ -343,7 +372,7 @@ public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationAndFo public void DeserializeRelationships_EmptyOneToManyDependent_NavigationIsNull() { // Arrange - var content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents"); + var content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents", isToManyData: true); var body = JsonConvert.SerializeObject(content); // Act @@ -351,7 +380,7 @@ public void DeserializeRelationships_EmptyOneToManyDependent_NavigationIsNull() // Assert Assert.Equal(1, result.Id); - Assert.Null(result.Dependents); + Assert.Empty(result.Dependents); Assert.Null(result.AttributeMember); } diff --git a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs index 58d380cee9..a6d06382af 100644 --- a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs @@ -1,4 +1,3 @@ -using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -91,7 +90,7 @@ public void ResourceWithRelationshipsToResourceObject_ResourceWithId_CanBuild() // Arrange var resource = new MultipleRelationshipsPrincipalPart { - PopulatedToOne = new OneToOneDependent { Id = 10 }, + PopulatedToOne = new OneToOneDependent { Id = 10 } }; // Act @@ -179,27 +178,5 @@ public void ResourceWithRequiredRelationshipsToResourceObject_DeviatingForeignKe var ro = (ResourceIdentifierObject)resourceObject.Relationships["principal"].Data; Assert.Equal("10", ro.Id); } - - [Fact] - public void ResourceWithRequiredRelationshipsToResourceObject_DeviatingForeignKeyAndNoNavigationWhileRelationshipIncluded_ThrowsNotSupportedException() - { - // Arrange - var resource = new OneToOneRequiredDependent { Principal = null, PrincipalId = 123 }; - var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); - - // Act & assert - Assert.ThrowsAny(() => _builder.Build(resource, relationships: relationships)); - } - - [Fact] - public void ResourceWithRequiredRelationshipsToResourceObject_EmptyResourceWhileRelationshipIncluded_ThrowsNotSupportedException() - { - // Arrange - var resource = new OneToOneRequiredDependent(); - var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); - - // Act & assert - Assert.ThrowsAny(() => _builder.Build(resource, relationships: relationships)); - } } } diff --git a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs index e6f43b0919..11100d3c01 100644 --- a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs @@ -14,7 +14,7 @@ public sealed class IncludedResourceObjectBuilderTests : SerializerTestsSetup [Fact] public void BuildIncluded_DeeplyNestedCircularChainOfSingleData_CanBuild() { - // Arrange + // Arrange var (article, author, _, reviewer, _) = GetAuthorChainInstances(); var authorChain = GetIncludedRelationshipsChain("author.blogs.reviewer.favoriteFood"); var builder = GetBuilder(); diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index c01f2f9b7e..7f628073c6 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.ComponentModel.Design; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization; @@ -14,16 +15,17 @@ public sealed class RequestDeserializerTests : DeserializerTestsSetup { private readonly RequestDeserializer _deserializer; private readonly Mock _fieldsManagerMock = new Mock(); + private readonly Mock _requestMock = new Mock(); public RequestDeserializerTests() { - _deserializer = new RequestDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, _mockHttpContextAccessor.Object); + _deserializer = new RequestDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, _mockHttpContextAccessor.Object, _requestMock.Object); } [Fact] public void DeserializeAttributes_VariousUpdatedMembers_RegistersTargetedFields() { // Arrange - SetupFieldsManager(out List attributesToUpdate, out List relationshipsToUpdate); + SetupFieldsManager(out HashSet attributesToUpdate, out HashSet relationshipsToUpdate); Document content = CreateTestResourceDocument(); var body = JsonConvert.SerializeObject(content); @@ -39,7 +41,7 @@ public void DeserializeAttributes_VariousUpdatedMembers_RegistersTargetedFields( public void DeserializeRelationships_MultipleDependentRelationships_RegistersUpdatedRelationships() { // Arrange - SetupFieldsManager(out List attributesToUpdate, out List relationshipsToUpdate); + SetupFieldsManager(out HashSet attributesToUpdate, out HashSet relationshipsToUpdate); var content = CreateDocumentWithRelationships("multiPrincipals"); content.SingleData.Relationships.Add("populatedToOne", CreateRelationshipData("oneToOneDependents")); content.SingleData.Relationships.Add("emptyToOne", CreateRelationshipData()); @@ -59,7 +61,7 @@ public void DeserializeRelationships_MultipleDependentRelationships_RegistersUpd public void DeserializeRelationships_MultiplePrincipalRelationships_RegistersUpdatedRelationships() { // Arrange - SetupFieldsManager(out List attributesToUpdate, out List relationshipsToUpdate); + SetupFieldsManager(out HashSet attributesToUpdate, out HashSet relationshipsToUpdate); var content = CreateDocumentWithRelationships("multiDependents"); content.SingleData.Relationships.Add("populatedToOne", CreateRelationshipData("oneToOnePrincipals")); content.SingleData.Relationships.Add("emptyToOne", CreateRelationshipData()); @@ -75,10 +77,10 @@ public void DeserializeRelationships_MultiplePrincipalRelationships_RegistersUpd Assert.Empty(attributesToUpdate); } - private void SetupFieldsManager(out List attributesToUpdate, out List relationshipsToUpdate) + private void SetupFieldsManager(out HashSet attributesToUpdate, out HashSet relationshipsToUpdate) { - attributesToUpdate = new List(); - relationshipsToUpdate = new List(); + attributesToUpdate = new HashSet(); + relationshipsToUpdate = new HashSet(); _fieldsManagerMock.Setup(m => m.Attributes).Returns(attributesToUpdate); _fieldsManagerMock.Setup(m => m.Relationships).Returns(relationshipsToUpdate); } diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 46eeb5fb27..9749d95ac9 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -349,95 +349,6 @@ public void SerializeSingle_NullWithLinksAndMeta_StillShowsLinksAndMeta() Assert.Equal(expected, serialized); } - [Fact] - public void SerializeSingleWithRequestRelationship_NullToOneRelationship_CanSerialize() - { - // Arrange - var resource = new OneToOnePrincipal { Id = 2, Dependent = null }; - var serializer = GetResponseSerializer(); - var requestRelationship = _resourceGraph.GetRelationships((OneToOnePrincipal t) => t.Dependent).First(); - serializer.RequestRelationship = requestRelationship; - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - var expectedFormatted = @"{ ""data"": null}"; - var expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingleWithRequestRelationship_PopulatedToOneRelationship_CanSerialize() - { - // Arrange - var resource = new OneToOnePrincipal { Id = 2, Dependent = new OneToOneDependent { Id = 1 } }; - var serializer = GetResponseSerializer(); - var requestRelationship = _resourceGraph.GetRelationships((OneToOnePrincipal t) => t.Dependent).First(); - serializer.RequestRelationship = requestRelationship; - - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - var expectedFormatted = @"{ - ""data"":{ - ""type"":""oneToOneDependents"", - ""id"":""1"" - } - }"; - - var expected = Regex.Replace(expectedFormatted, @"\s+", ""); - - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingleWithRequestRelationship_EmptyToManyRelationship_CanSerialize() - { - // Arrange - var resource = new OneToManyPrincipal { Id = 2, Dependents = new HashSet() }; - var serializer = GetResponseSerializer(); - var requestRelationship = _resourceGraph.GetRelationships((OneToManyPrincipal t) => t.Dependents).First(); - serializer.RequestRelationship = requestRelationship; - - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - var expectedFormatted = @"{ ""data"": [] }"; - var expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_CanSerialize() - { - // Arrange - var resource = new OneToManyPrincipal { Id = 2, Dependents = new HashSet { new OneToManyDependent { Id = 1 } } }; - var serializer = GetResponseSerializer(); - var requestRelationship = _resourceGraph.GetRelationships((OneToManyPrincipal t) => t.Dependents).First(); - serializer.RequestRelationship = requestRelationship; - - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - var expectedFormatted = @"{ - ""data"":[{ - ""type"":""oneToManyDependents"", - ""id"":""1"" - }] - }"; - - var expected = Regex.Replace(expectedFormatted, @"\s+", ""); - - Assert.Equal(expected, serialized); - } - [Fact] public void SerializeError_Error_CanSerialize() { diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index c9b7e2b28b..f4661c2acc 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Internal; @@ -76,6 +77,11 @@ private JsonApiResourceService GetService() var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; var paginationContext = new PaginationContext(); + var getResourcesByIds = new Mock().Object; + var targetedFields = new Mock().Object; + var resourceContextProvider = new Mock().Object; + var resourceHookExecutor = new NeverResourceHookExecutorFacade(); + var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); var request = new JsonApiRequest { @@ -85,8 +91,9 @@ private JsonApiResourceService GetService() .Single(x => x.PublicName == "collection") }; - return new JsonApiResourceService(_repositoryMock.Object, composer, paginationContext, options, - NullLoggerFactory.Instance, request, changeTracker, resourceFactory, null); + return new JsonApiResourceService(_repositoryMock.Object, getResourcesByIds, composer, + paginationContext, options, NullLoggerFactory.Instance, request, changeTracker, resourceFactory, + targetedFields, resourceContextProvider, resourceHookExecutor); } } } From abef99e5ae883e3585f37aec286e1ab3a8aa5d6c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Nov 2020 13:28:49 +0100 Subject: [PATCH 02/24] Various cleanups based on diff with master. --- .../Controllers/TodoItemsCustomController.cs | 2 +- .../Controllers/TodoItemsTestController.cs | 20 +++-- .../Services/WorkItemService.cs | 25 +++--- .../ApplicationBuilderExtensions.cs | 6 +- ...olver.cs => IInverseNavigationResolver.cs} | 4 +- ...solver.cs => InverseNavigationResolver.cs} | 4 +- .../JsonApiApplicationBuilder.cs | 2 +- .../ServiceCollectionExtensions.cs | 5 +- .../Controllers/JsonApiController.cs | 6 +- .../Errors/IHasMultipleErrors.cs | 10 +++ .../Errors/InvalidModelStateException.cs | 2 +- .../Errors/InvalidRequestBodyException.cs | 28 ++++--- ...ourcesInRelationshipsNotFoundException.cs} | 4 +- .../NeverResourceHookExecutorFacade.cs | 2 +- .../Middleware/ExceptionHandler.cs | 10 +-- .../Queries/Internal/Parsing/FilterParser.cs | 4 +- .../Repositories/DbContextExtensions.cs | 21 +++-- .../Repositories/IResourceRepository.cs | 4 +- .../ResourceRepositoryAccessor.cs | 4 +- .../Annotations/HasManyThroughAttribute.cs | 18 ++--- .../Annotations/RelationshipAttribute.cs | 3 +- .../Resources/IdentifiableComparer.cs | 5 +- .../Resources/IdentifiableExtensions.cs | 6 +- .../Resources/ResourceFactory.cs | 2 +- .../Serialization/BaseDeserializer.cs | 76 ++++++------------- .../Serialization/Building/LinkBuilder.cs | 2 +- .../Serialization/FieldsToSerialize.cs | 10 +-- .../Serialization/ResponseSerializer.cs | 3 +- .../Services/IAddToRelationshipService.cs | 6 +- .../Services/IGetResourcesByIds.cs | 1 + .../IRemoveFromRelationshipService.cs | 6 +- .../Services/ISetRelationshipService.cs | 3 +- .../Services/JsonApiResourceService.cs | 4 +- src/JsonApiDotNetCore/TypeHelper.cs | 7 -- .../Spec/FetchingRelationshipsTests.cs | 2 + .../Acceptance/TodoItemControllerTests.cs | 4 +- .../CompositeKeys/CompositeKeyTests.cs | 1 + .../ModelStateValidationTests.cs | 9 --- .../SoftDeletion/SoftDeletionTests.cs | 43 +++++++++++ .../Writing/Creating/CreateResourceTests.cs | 2 +- ...eateResourceWithToManyRelationshipTests.cs | 10 +-- ...reateResourceWithToOneRelationshipTests.cs | 2 +- .../IntegrationTests/Writing/RgbColor.cs | 1 - .../AddToToManyRelationshipTests.cs | 2 +- .../RemoveFromToManyRelationshipTests.cs | 2 +- .../ReplaceToManyRelationshipTests.cs | 2 +- .../UpdateToOneRelationshipTests.cs | 2 +- .../ReplaceToManyRelationshipTests.cs | 10 +-- .../Updating/Resources/UpdateResourceTests.cs | 2 +- .../Resources/UpdateToOneRelationshipTests.cs | 2 +- .../IServiceCollectionExtensionsTests.cs | 13 ++-- .../ResourceHooks/ResourceHooksTestsSetup.cs | 17 ++--- 52 files changed, 239 insertions(+), 202 deletions(-) rename src/JsonApiDotNetCore/Configuration/{IInverseRelationshipResolver.cs => IInverseNavigationResolver.cs} (81%) rename src/JsonApiDotNetCore/Configuration/{InverseRelationshipResolver.cs => InverseNavigationResolver.cs} (91%) create mode 100644 src/JsonApiDotNetCore/Errors/IHasMultipleErrors.cs rename src/JsonApiDotNetCore/Errors/{SecondaryResourcesNotFoundException.cs => ResourcesInRelationshipsNotFoundException.cs} (82%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index ab63c950d9..16fd697688 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -134,7 +134,7 @@ public async Task PatchAsync(TId id, [FromBody] T resource) public async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds) { await _resourceService.SetRelationshipAsync(id, relationshipName, secondaryResourceIds); - + return Ok(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index e53ce44723..c6d945372e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -40,16 +41,16 @@ public TodoItemsTestController( [HttpGet("{id}")] public override async Task GetAsync(int id) => await base.GetAsync(id); - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(int id, string relationshipName) - => await base.GetRelationshipAsync(id, relationshipName); - [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(int id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(int id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); + [HttpPost] - public override async Task PostAsync(TodoItem resource) + public override async Task PostAsync([FromBody] TodoItem resource) { await Task.Yield(); @@ -59,6 +60,11 @@ public override async Task PostAsync(TodoItem resource) }); } + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + int id, string relationshipName, [FromBody] ISet secondaryResourceIds) + => await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds); + [HttpPatch("{id}")] public override async Task PatchAsync(int id, [FromBody] TodoItem resource) { @@ -79,5 +85,9 @@ public override async Task DeleteAsync(int id) return NotFound(); } + + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(int id, string relationshipName, [FromBody] ISet secondaryResourceIds) + => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds); } } diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 8e87d51763..5ee86f38ae 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -31,7 +31,7 @@ public async Task> GetAsync() public async Task GetAsync(int id) { var query = await QueryAsync(async connection => - await connection.QueryAsync(@"select * from ""WorkItems"" where ""Id""=@id", new { id })); + await connection.QueryAsync(@"select * from ""WorkItems"" where ""Id""=@id", new {id})); return query.Single(); } @@ -50,16 +50,20 @@ public async Task CreateAsync(WorkItem resource) { return (await QueryAsync(async connection => { - var query = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values (@description, @isLocked, @ordinal, @uniqueId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; - var result = await connection.QueryAsync(query, new { description = resource.Title, ordinal = resource.DurationInHours, uniqueId = resource.ProjectId, isLocked = resource.IsBlocked }); - return result; + var query = + @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + + @"(@description, @isLocked, @ordinal, @uniqueId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; + + return await connection.QueryAsync(query, new + { + description = resource.Title, ordinal = resource.DurationInHours, uniqueId = resource.ProjectId, isLocked = resource.IsBlocked + }); })).SingleOrDefault(); } - public async Task DeleteAsync(int id) + public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) { - await QueryAsync(async connection => - await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new { id })); + throw new NotImplementedException(); } public Task UpdateAsync(int id, WorkItem resource) @@ -71,10 +75,11 @@ public Task SetRelationshipAsync(int id, string relationshipName, object seconda { throw new NotImplementedException(); } - - public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) + + public async Task DeleteAsync(int id) { - throw new NotImplementedException(); + await QueryAsync(async connection => + await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new {id})); } public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 99b671e411..b176df1ead 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -25,9 +25,9 @@ public static void UseJsonApi(this IApplicationBuilder builder) if (builder == null) throw new ArgumentNullException(nameof(builder)); using var scope = builder.ApplicationServices.GetRequiredService().CreateScope(); - var inverseRelationshipResolver = scope.ServiceProvider.GetRequiredService(); - inverseRelationshipResolver.Resolve(); - + var inverseNavigationResolver = scope.ServiceProvider.GetRequiredService(); + inverseNavigationResolver.Resolve(); + var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); jsonApiApplicationBuilder.ConfigureMvcOptions = options => { diff --git a/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs similarity index 81% rename from src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs rename to src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs index c9e4e10722..227901a8be 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseRelationshipResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Responsible for populating the property. + /// Responsible for populating . /// /// This service is instantiated in the configure phase of the application. /// @@ -12,7 +12,7 @@ namespace JsonApiDotNetCore.Configuration /// you will need to override this service, or pass along the InverseNavigationProperty in /// the RelationshipAttribute. /// - public interface IInverseRelationshipResolver + public interface IInverseNavigationResolver { /// /// This method is called upon startup by JsonApiDotNetCore. It resolves inverse relationships. diff --git a/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs similarity index 91% rename from src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs rename to src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index 12491d38cd..bc93eff7e0 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseRelationshipResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCore.Configuration { /// - public class InverseRelationshipResolver : IInverseRelationshipResolver + public class InverseNavigationResolver : IInverseNavigationResolver { private readonly IResourceContextProvider _resourceContextProvider; private readonly IEnumerable _dbContextResolvers; - public InverseRelationshipResolver(IResourceContextProvider resourceContextProvider, + public InverseNavigationResolver(IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers) { _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index f33d6dcda9..e4441d3f4f 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -145,7 +145,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.AddScoped(); _services.AddScoped(); - _services.TryAddScoped(); + _services.TryAddScoped(); } private void AddMiddlewareLayer() diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 1c1e77f436..452fcd01cc 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -78,9 +78,8 @@ public static IServiceCollection AddClientSerialization(this IServiceCollection /// /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, - /// such as , and various others. + /// such as , and the various others. /// - /// public static IServiceCollection AddResourceService(this IServiceCollection services) { if (services == null) throw new ArgumentNullException(nameof(services)); @@ -120,6 +119,8 @@ public static IServiceCollection AddResourceService(this IServiceColle return services; } + // TODO: Should add AddResourceRepository, which registers the read/write/shared interfaces (similar to AddResourceService) + update docs. + private static ResourceDescriptor TryGetResourceTypeFromServiceImplementation(Type serviceType) { foreach (var @interface in serviceType.GetInterfaces()) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index dcdbaee2aa..5d56ceeace 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -50,12 +50,12 @@ public JsonApiController( /// [HttpGet("{id}")] public override async Task GetAsync(TId id) => await base.GetAsync(id); - + /// [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); - + /// [HttpGet("{id}/relationships/{relationshipName}")] public override async Task GetRelationshipAsync(TId id, string relationshipName) @@ -88,7 +88,7 @@ public override async Task PatchRelationshipAsync( /// [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); - + /// [HttpDelete("{id}/relationships/{relationshipName}")] public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds) diff --git a/src/JsonApiDotNetCore/Errors/IHasMultipleErrors.cs b/src/JsonApiDotNetCore/Errors/IHasMultipleErrors.cs new file mode 100644 index 0000000000..87e91922e4 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/IHasMultipleErrors.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + public interface IHasMultipleErrors + { + public IReadOnlyCollection Errors { get; } + } +} diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index d663a9bad8..51ddea6a09 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.Errors /// /// The error that is thrown when model state validation fails. /// - public class InvalidModelStateException : Exception + public class InvalidModelStateException : Exception, IHasMultipleErrors { public IReadOnlyCollection Errors { get; } diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index e49a453e07..e9f2bf0a75 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using System.Text; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Errors @@ -9,38 +10,35 @@ namespace JsonApiDotNetCore.Errors /// public sealed class InvalidRequestBodyException : JsonApiException { - private readonly string _details; - private readonly string _requestBody; - public InvalidRequestBodyException(string reason, string details, string requestBody, Exception innerException = null) : base(new Error(HttpStatusCode.UnprocessableEntity) { Title = reason != null ? "Failed to deserialize request body: " + reason - : "Failed to deserialize request body." + : "Failed to deserialize request body.", + Detail = FormatErrorDetail(details, requestBody, innerException) }, innerException) { - _details = details; - _requestBody = requestBody; - - UpdateErrorDetail(); } - private void UpdateErrorDetail() + private static string FormatErrorDetail(string details, string requestBody, Exception innerException) { - string text = _details ?? InnerException?.Message; + var builder = new StringBuilder(); + builder.Append(details ?? innerException?.Message); - if (_requestBody != null) + if (requestBody != null) { - if (text != null) + if (builder.Length > 0) { - text += " - "; + builder.Append(" - "); } - text += "Request body: <<" + _requestBody + ">>"; + builder.Append("Request body: <<"); + builder.Append(requestBody); + builder.Append(">>"); } - Error.Detail = text; + return builder.Length > 0 ? builder.ToString() : null; } } } diff --git a/src/JsonApiDotNetCore/Errors/SecondaryResourcesNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs similarity index 82% rename from src/JsonApiDotNetCore/Errors/SecondaryResourcesNotFoundException.cs rename to src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs index d43c063902..7c7a9e5a2a 100644 --- a/src/JsonApiDotNetCore/Errors/SecondaryResourcesNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs @@ -9,11 +9,11 @@ namespace JsonApiDotNetCore.Errors /// /// The error that is thrown when referencing one or more non-existing resources in one or more relationships. /// - public sealed class SecondaryResourcesNotFoundException : Exception + public sealed class ResourcesInRelationshipsNotFoundException : Exception, IHasMultipleErrors { public IReadOnlyCollection Errors { get; } - public SecondaryResourcesNotFoundException(IEnumerable missingResources) + public ResourcesInRelationshipsNotFoundException(IEnumerable missingResources) { Errors = missingResources.Select(CreateError).ToList(); } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs index 7f3cc16f94..beb93a151a 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Hooks.Internal { /// - /// Facade for hooks that does nothing, which is used when is false. + /// Facade for hooks that never executes any callbacks, which is used when is false. /// public sealed class NeverResourceHookExecutorFacade : IResourceHookExecutorFacade { diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 346072ed5c..1207efbb95 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -66,15 +66,9 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) { if (exception == null) throw new ArgumentNullException(nameof(exception)); - if (exception is InvalidModelStateException modelStateException) + if (exception is IHasMultipleErrors exceptionWithMultipleErrors) { - return new ErrorDocument(modelStateException.Errors); - } - - if (exception is SecondaryResourcesNotFoundException - resourcesInRelationshipAssignmentNotFound) - { - return new ErrorDocument(resourcesInRelationshipAssignmentNotFound.Errors); + return new ErrorDocument(exceptionWithMultipleErrors.Errors); } Error error = exception is JsonApiException jsonApiException diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index 0f2904fc9f..791089f00d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -295,7 +295,9 @@ protected LiteralConstantExpression ParseConstant() private string DeObfuscateStringId(Type resourceType, string stringId) { - return TypeHelper.ConvertStringIdToTypedId(resourceType, stringId, _resourceFactory).ToString(); + var tempResource = _resourceFactory.CreateInstance(resourceType); + tempResource.StringId = stringId; + return tempResource.GetTypedId().ToString(); } protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 04b418f7dc..7d0df655f2 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -7,6 +7,9 @@ namespace JsonApiDotNetCore.Repositories { public static class DbContextExtensions { + /// + /// If not already tracked, attaches the specified resource to the change tracker in state. + /// public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); @@ -22,6 +25,9 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti return trackedIdentifiable; } + /// + /// Searches the change tracker for an entity that matches the type and ID of . + /// public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); @@ -37,17 +43,22 @@ public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifia return entityEntry?.Entity; } + /// + /// Calls for the specified type. + /// public static IQueryable Set(this DbContext dbContext, Type entityType) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); if (entityType == null) throw new ArgumentNullException(nameof(entityType)); - var getDbSetOpen = typeof(DbContext).GetMethod(nameof(DbContext.Set)); - - var getDbSetGeneric = getDbSetOpen.MakeGenericMethod(entityType); - var dbSet = (IQueryable)getDbSetGeneric.Invoke(dbContext, null); + var genericSetMethod = typeof(DbContext).GetMethod(nameof(DbContext.Set)); + if (genericSetMethod == null) + { + throw new InvalidOperationException($"Method '{nameof(DbContext)}.{nameof(DbContext.Set)}' does not exist."); + } - return dbSet; + var constructedSetMethod = genericSetMethod.MakeGenericMethod(entityType); + return (IQueryable)constructedSetMethod.Invoke(dbContext, null); } } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs index f5ae656556..9400612d82 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Repositories /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. /// /// The resource type. - public interface IResourceRepository + public interface IResourceRepository : IResourceRepository, IResourceReadRepository, IResourceWriteRepository where TResource : class, IIdentifiable { @@ -17,7 +17,7 @@ public interface IResourceRepository /// /// The resource type. /// The resource identifier type. - public interface IResourceRepository + public interface IResourceRepository : IResourceReadRepository, IResourceWriteRepository where TResource : class, IIdentifiable { diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 63644f301f..04263c56af 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -26,11 +26,11 @@ public async Task> GetAsync(Type resourceType if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); if (layer == null) throw new ArgumentNullException(nameof(layer)); - dynamic repository = GetRepository(resourceType); + dynamic repository = GetReadRepository(resourceType); return (IReadOnlyCollection) await repository.GetAsync(layer); } - protected object GetRepository(Type resourceType) + protected object GetReadRepository(Type resourceType) { var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 4747abe26d..66584d290d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -122,15 +122,15 @@ public override object GetValue(object resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - var value = ThroughProperty.GetValue(resource); - if (value == null) + var throughEntity = ThroughProperty.GetValue(resource); + if (throughEntity == null) { return null; } - IEnumerable rightResources = ((IEnumerable) value) + IEnumerable rightResources = ((IEnumerable) throughEntity) .Cast() - .Select(joinEntity => RightProperty.GetValue(joinEntity)); + .Select(rightResource => RightProperty.GetValue(rightResource)); return TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } @@ -152,13 +152,13 @@ public override void SetValue(object resource, object newValue) else { List throughResources = new List(); - foreach (IIdentifiable identifiable in (IEnumerable)newValue) + foreach (IIdentifiable rightResource in (IEnumerable)newValue) { - var throughResource = TypeHelper.CreateInstance(ThroughType); + var throughEntity = TypeHelper.CreateInstance(ThroughType); - LeftProperty.SetValue(throughResource, resource); - RightProperty.SetValue(throughResource, identifiable); - throughResources.Add(throughResource); + LeftProperty.SetValue(throughEntity, resource); + RightProperty.SetValue(throughEntity, rightResource); + throughResources.Add(throughEntity); } var typedCollection = TypeHelper.CopyToTypedCollection(throughResources, ThroughProperty.PropertyType); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index eeab77e715..f7cf87099c 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -13,7 +13,8 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute private LinkTypes _links; /// - /// The property name of the EF Core inverse navigation, which may or may not be exposed as a json:api relationship. + /// The property name of the EF Core inverse navigation, which may or may not exist. + /// Even if it exists, it may not be exposed as a json:api relationship. /// /// /// - /// Compares `IIdentifiable` instances with each other based on StringId. + /// Compares `IIdentifiable` instances with each other based on their type and . /// public sealed class IdentifiableComparer : IEqualityComparer { @@ -30,7 +31,7 @@ public bool Equals(IIdentifiable x, IIdentifiable y) public int GetHashCode(IIdentifiable obj) { - return obj.StringId != null ? obj.StringId.GetHashCode() : 0; + return obj.StringId != null ? HashCode.Combine(obj.GetType(), obj.StringId) : 0; } } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 1b62fe81af..59918a25d9 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -3,9 +3,9 @@ namespace JsonApiDotNetCore.Resources { - public static class IdentifiableExtensions + internal static class IdentifiableExtensions { - internal static object GetTypedId(this IIdentifiable identifiable) + public static object GetTypedId(this IIdentifiable identifiable) { if (identifiable == null) throw new ArgumentNullException(nameof(identifiable)); @@ -13,7 +13,7 @@ internal static object GetTypedId(this IIdentifiable identifiable) if (property == null) { - throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not have an Id property."); + throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not have an 'Id' property."); } return property.GetValue(identifiable); diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 568e964c3a..e0a7a6646e 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -27,7 +27,7 @@ public IIdentifiable CreateInstance(Type resourceType) return InnerCreateInstance(resourceType, _serviceProvider); } - + /// public TResource CreateInstance() where TResource : IIdentifiable diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 423dc2cdeb..c9b306e6f0 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -125,9 +125,9 @@ protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictio { SetHasOneRelationship(resource, hasOneAttribute, relationshipData); } - else + else if (attr is HasManyAttribute hasManyAttribute) { - SetHasManyRelationship(resource, (HasManyAttribute)attr, relationshipData); + SetHasManyRelationship(resource, hasManyAttribute, relationshipData); } } @@ -173,7 +173,7 @@ private ResourceContext GetExistingResourceContext(string publicName) if (resourceContext == null) { throw new JsonApiSerializationException("Request body includes unknown resource type.", - $"Resource of type '{publicName}' does not exist."); + $"Resource type '{publicName}' does not exist."); } return resourceContext; @@ -182,54 +182,22 @@ private ResourceContext GetExistingResourceContext(string publicName) /// /// Sets a HasOne relationship on a parsed resource. /// - private void SetHasOneRelationship(IIdentifiable resource, - HasOneAttribute hasOneRelationship, - RelationshipEntry relationshipData) + private void SetHasOneRelationship(IIdentifiable resource, HasOneAttribute hasOneRelationship, RelationshipEntry relationshipData) { if (relationshipData.ManyData != null) { - throw new JsonApiSerializationException("Expected single data for to-one relationship.", - $"Expected single data for '{hasOneRelationship.PublicName}' relationship."); - } - - var rio = (ResourceIdentifierObject)relationshipData.Data; - var relatedId = rio?.Id; - - Type relationshipType = hasOneRelationship.RightType; - - if (relationshipData.SingleData != null) - { - AssertHasType(relationshipData.SingleData, hasOneRelationship); - AssertHasId(relationshipData.SingleData, hasOneRelationship); - - var rightResourceContext = GetExistingResourceContext(relationshipData.SingleData.Type); - AssertRightTypeIsCompatible(rightResourceContext, hasOneRelationship); - - relationshipType = rightResourceContext.ResourceType; + throw new JsonApiSerializationException("Expected single data element for to-one relationship.", + $"Expected single data element for '{hasOneRelationship.PublicName}' relationship."); } - SetPrincipalSideOfHasOneRelationship(resource, hasOneRelationship, relatedId, relationshipType); + var rightResource = CreateRightResource(hasOneRelationship, relationshipData.SingleData); + hasOneRelationship.SetValue(resource, rightResource); // depending on if this base parser is used client-side or server-side, // different additional processing per field needs to be executed. AfterProcessField(resource, hasOneRelationship, relationshipData); } - private void SetPrincipalSideOfHasOneRelationship(IIdentifiable resource, HasOneAttribute attr, string relatedId, - Type relationshipType) - { - if (relatedId == null) - { - attr.SetValue(resource, null); - } - else - { - var relatedInstance = ResourceFactory.CreateInstance(relationshipType); - relatedInstance.StringId = relatedId; - attr.SetValue(resource, relatedInstance); - } - } - /// /// Sets a HasMany relationship. /// @@ -240,12 +208,12 @@ private void SetHasManyRelationship( { if (relationshipData.ManyData == null) { - throw new JsonApiSerializationException("Expected data[] for to-many relationship.", - $"Expected data[] for '{hasManyRelationship.PublicName}' relationship."); + throw new JsonApiSerializationException("Expected data[] element for to-many relationship.", + $"Expected data[] element for '{hasManyRelationship.PublicName}' relationship."); } var rightResources = relationshipData.ManyData - .Select(rio => CreateRightResourceForHasMany(hasManyRelationship, rio)) + .Select(rio => CreateRightResource(hasManyRelationship, rio)) .ToHashSet(IdentifiableComparer.Instance); var convertedCollection = TypeHelper.CopyToTypedCollection(rightResources, hasManyRelationship.Property.PropertyType); @@ -254,18 +222,24 @@ private void SetHasManyRelationship( AfterProcessField(resource, hasManyRelationship, relationshipData); } - private IIdentifiable CreateRightResourceForHasMany(HasManyAttribute hasManyRelationship, ResourceIdentifierObject rio) + private IIdentifiable CreateRightResource(RelationshipAttribute relationship, + ResourceIdentifierObject resourceIdentifierObject) { - AssertHasType(rio, hasManyRelationship); - AssertHasId(rio, hasManyRelationship); + if (resourceIdentifierObject != null) + { + AssertHasType(resourceIdentifierObject, relationship); + AssertHasId(resourceIdentifierObject, relationship); + + var rightResourceContext = GetExistingResourceContext(resourceIdentifierObject.Type); + AssertRightTypeIsCompatible(rightResourceContext, relationship); - var rightResourceContext = GetExistingResourceContext(rio.Type); - AssertRightTypeIsCompatible(rightResourceContext, hasManyRelationship); + var rightInstance = ResourceFactory.CreateInstance(rightResourceContext.ResourceType); + rightInstance.StringId = resourceIdentifierObject.Id; - var rightInstance = ResourceFactory.CreateInstance(rightResourceContext.ResourceType); - rightInstance.StringId = rio.Id; + return rightInstance; + } - return rightInstance; + return null; } private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) diff --git a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs index 9773b7630b..883329e7f2 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs @@ -283,7 +283,7 @@ private bool ShouldAddResourceLink(ResourceContext resourceContext, LinkTypes li { return false; } - + if (resourceContext.ResourceLinks != LinkTypes.NotConfigured) { return resourceContext.ResourceLinks.HasFlag(link); diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs index f9740904fc..9524e3a0d8 100644 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -16,18 +16,18 @@ public class FieldsToSerialize : IFieldsToSerialize private readonly IResourceGraph _resourceGraph; private readonly IEnumerable _constraintProviders; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IJsonApiRequest _jsonApiRequest; + private readonly IJsonApiRequest _request; public FieldsToSerialize( IResourceGraph resourceGraph, IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, - IJsonApiRequest jsonApiRequest) + IJsonApiRequest request) { _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); - _jsonApiRequest = jsonApiRequest ?? throw new ArgumentNullException(nameof(jsonApiRequest)); + _request = request ?? throw new ArgumentNullException(nameof(request)); } /// @@ -35,7 +35,7 @@ public IReadOnlyCollection GetAttributes(Type resourceType, Relat { if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); - if (_jsonApiRequest.Kind == EndpointKind.Relationship) + if (_request.Kind == EndpointKind.Relationship) { return Array.Empty(); } @@ -88,7 +88,7 @@ public IReadOnlyCollection GetRelationships(Type type) { if (type == null) throw new ArgumentNullException(nameof(type)); - return _jsonApiRequest.Kind == EndpointKind.Relationship + return _request.Kind == EndpointKind.Relationship ? Array.Empty() : _resourceGraph.GetRelationships(type); } diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index c608b54093..e816e71f4d 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -22,7 +22,8 @@ namespace JsonApiDotNetCore.Serialization /// /// Type of the resource associated with the scope of the request /// for which this serializer is used. - public class ResponseSerializer : BaseSerializer, IJsonApiSerializer where TResource : class, IIdentifiable + public class ResponseSerializer : BaseSerializer, IJsonApiSerializer + where TResource : class, IIdentifiable { private readonly IFieldsToSerialize _fieldsToSerialize; private readonly IJsonApiOptions _options; diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs index 4d235cffcb..480571c846 100644 --- a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -6,10 +6,12 @@ namespace JsonApiDotNetCore.Services { /// public interface IAddToRelationshipService : IAddToRelationshipService - where TResource : class, IIdentifiable { } + where TResource : class, IIdentifiable + { } /// - public interface IAddToRelationshipService where TResource : class, IIdentifiable + public interface IAddToRelationshipService + where TResource : class, IIdentifiable { /// /// Handles a json:api request to add resources to a to-many relationship. diff --git a/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs index dbb0ee4150..24828fbc4e 100644 --- a/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs +++ b/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs @@ -8,6 +8,7 @@ namespace JsonApiDotNetCore.Services /// /// Gets resources by set of identifiers for a type that is known at runtime. /// + // TODO: Refactor this type (it is a helper method). public interface IGetResourcesByIds { /// diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index bbac022341..9511b6583c 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -6,10 +6,12 @@ namespace JsonApiDotNetCore.Services { /// public interface IRemoveFromRelationshipService : IRemoveFromRelationshipService - where TResource : class, IIdentifiable { } + where TResource : class, IIdentifiable + { } /// - public interface IRemoveFromRelationshipService where TResource : class, IIdentifiable + public interface IRemoveFromRelationshipService + where TResource : class, IIdentifiable { /// /// Handles a json:api request to remove resources from a to-many relationship. diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index af34622f4b..2db24d4992 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -9,7 +9,8 @@ public interface ISetRelationshipService : ISetRelationshipService - public interface ISetRelationshipService where TResource : class, IIdentifiable + public interface ISetRelationshipService + where TResource : class, IIdentifiable { /// /// Handles a json:api request to perform a complete replacement of a relationship on an existing resource. diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 985e00384c..2c31d6b7bf 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -439,7 +439,7 @@ private async Task AssertRightResourcesInRelationshipsExistAsync(IEnumerable /// Extension to use the LINQ cast method in a non-generic way: /// diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index a5f4012f56..dbb1b5a29f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -60,6 +60,8 @@ public async Task When_getting_existing_ToOne_relationship_it_should_succeed() var json = JsonConvert.DeserializeObject(body).ToString(); + // TODO: links/related was removed from the expected response body here, which violates the json:api spec. + string expected = @"{ ""links"": { ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"" diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs index b6bbaf3d0a..8c9db7094d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs @@ -82,7 +82,7 @@ public async Task Can_Get_TodoItem_ById() // Arrange var todoItem = _todoItemFaker.Generate(); todoItem.Owner = _personFaker.Generate(); - + _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -143,7 +143,7 @@ public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() // Arrange var person1 = _personFaker.Generate(); var person2 = _personFaker.Generate(); - + _context.People.AddRange(person1, person2); await _context.SaveChangesAsync(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index b40a17fd7e..6e5504e592 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -23,6 +23,7 @@ public CompositeKeyTests(IntegrationTestContext { + // TODO: Replace with single call (see TODO in ServiceCollectionExtensions). services.AddScoped, CarRepository>(); services.AddScoped, CarRepository>(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index f9b05f28a7..a4209a5cf3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -820,15 +820,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var directoryInDatabase = await dbContext.Directories - .Include(d => d.Parent) - .FirstAsync(d => d.Id == directory.Id); - - directoryInDatabase.Parent.Id.Should().Be(otherParent.Id); - }); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 20d6e77520..aa8c49e2b0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -393,5 +393,48 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); responseDocument.Errors[0].Source.Parameter.Should().BeNull(); } + + [Fact(Skip = "TODO: Make this test work again, now that we fetch the primary resource.")] + public async Task Cannot_update_relationship_for_deleted_parent() + { + // Arrange + var company = new Company + { + IsSoftDeleted = true, + Departments = new List + { + new Department + { + Name = "Marketing" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(company); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/companies/{company.StringId}/relationships/departments"; + + var requestBody = new + { + data = new object[0] + }; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs index ac4c0d4c89..82eaebba6f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs @@ -442,7 +442,7 @@ public async Task Cannot_create_resource_for_unknown_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs index c20ab0ea9d..1d3b71b177 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -395,7 +395,7 @@ public async Task Cannot_create_for_unknown_relationship_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] @@ -623,8 +623,8 @@ public async Task Cannot_create_with_null_data_in_HasMany_relationship() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'subscribers' relationship. - Request body: <<"); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element for 'subscribers' relationship. - Request body: <<"); } [Fact] @@ -656,8 +656,8 @@ public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs index 40f5be4404..c552a3b399 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -349,7 +349,7 @@ public async Task Cannot_create_for_unknown_relationship_type() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs index e6915211a8..0b260fb0df 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs @@ -1,4 +1,3 @@ -using System; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 9668064291..53cea64a81 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -286,7 +286,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index e342229c51..b8b7dbcaae 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -282,7 +282,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index ced3effb3d..b8e234ab6a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -328,7 +328,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index 8534fc0e5d..fbe765e1cb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -338,7 +338,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index a2b1da6dff..f2efb8504b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -504,7 +504,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] @@ -789,8 +789,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'subscribers' relationship. - Request body: <<"); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element for 'subscribers' relationship. - Request body: <<"); } [Fact] @@ -831,8 +831,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); - responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 640d1d8c27..5f3dd340d1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -619,7 +619,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index f018fd6cbe..c00a15b211 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -518,7 +518,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - responseDocument.Errors[0].Detail.Should().StartWith("Resource of type 'doesNotExist' does not exist. - Request body: <<"); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); } [Fact] diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 481f5b5026..bf7694f033 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -159,33 +159,32 @@ public class GuidResource : Identifiable { } private class IntResourceService : IResourceService { - public Task CreateAsync(IntResource resource) => throw new NotImplementedException(); - public Task DeleteAsync(int id) => throw new NotImplementedException(); public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(int id) => throw new NotImplementedException(); public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); + public Task CreateAsync(IntResource resource) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource resource) => throw new NotImplementedException(); public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); - public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task DeleteAsync(int id) => throw new NotImplementedException(); public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService { - public Task CreateAsync(GuidResource resource) => throw new NotImplementedException(); - public Task DeleteAsync(Guid id) => throw new NotImplementedException(); public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(Guid id) => throw new NotImplementedException(); public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); + public Task CreateAsync(GuidResource resource) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource resource) => throw new NotImplementedException(); public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); - public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id) => throw new NotImplementedException(); public Task RemoveFromToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } - public class TestContext : DbContext { public TestContext(DbContextOptions options) : base(options) diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index cfa5339a02..38028158cb 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -367,20 +367,15 @@ private IResourceReadRepository CreateTestRepository(AppDbC { var serviceProvider = ((IInfrastructure) dbContext).Instance; var resourceFactory = new ResourceFactory(serviceProvider); - IDbContextResolver resolver = CreateTestDbResolver(dbContext); + IDbContextResolver resolver = CreateTestDbResolver(dbContext); var targetedFields = new TargetedFields(); var getResourcesByIds = new Mock().Object; - return new EntityFrameworkCoreRepository( - targetedFields, - resolver, - resourceGraph, - resourceFactory, - new List(), - getResourcesByIds, - NullLoggerFactory.Instance); + + return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, + resourceFactory, new List(), getResourcesByIds, NullLoggerFactory.Instance); } - private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) where TModel : class, IIdentifiable + private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) { var mock = new Mock(); mock.Setup(r => r.GetContext()).Returns(dbContext); @@ -390,7 +385,7 @@ private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) private void ResolveInverseRelationships(AppDbContext context) { var dbContextResolvers = new[] {new DbContextResolver(context)}; - var inverseRelationships = new InverseRelationshipResolver(_resourceGraph, dbContextResolvers); + var inverseRelationships = new InverseNavigationResolver(_resourceGraph, dbContextResolvers); inverseRelationships.Resolve(); } From a373127edeb0f55a6b2a8ced0be82805e38f1b45 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Nov 2020 15:02:55 +0100 Subject: [PATCH 03/24] refactor: reduced ResourceService dependencies --- .../Services/CustomArticleService.cs | 9 +- .../JsonApiApplicationBuilder.cs | 1 + .../Errors/ResourceIdIsReadOnlyException.cs | 19 --- .../DataStoreUpdateFailureInspector.cs | 107 +++++++++++++ .../EntityFrameworkCoreRepository.cs | 13 +- .../Serialization/RequestDeserializer.cs | 15 +- .../Services/GetResourcesByIds.cs | 8 +- .../Services/JsonApiResourceService.cs | 143 ++++-------------- .../ServiceDiscoveryFacadeTests.cs | 9 +- .../Updating/Resources/UpdateResourceTests.cs | 8 +- .../Services/DefaultResourceService_Tests.cs | 9 +- 11 files changed, 180 insertions(+), 161 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs create mode 100644 src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 62025f5e98..692fa235ab 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -15,7 +15,6 @@ public class CustomArticleService : JsonApiResourceService
{ public CustomArticleService( IResourceRepository
repository, - IGetResourcesByIds getResourcesById, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -23,11 +22,11 @@ public CustomArticleService( IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, - ITargetedFields targetedFields, - IResourceContextProvider resourceContextProvider, + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, IResourceHookExecutorFacade hookExecutor) - : base(repository, getResourcesById, queryLayerComposer, paginationContext, options, loggerFactory, - request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceFactory, dataStoreUpdateFailureInspector, + hookExecutor) { } public override async Task
GetAsync(int id) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index e4441d3f4f..b1f504f4f7 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -146,6 +146,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.AddScoped(); _services.AddScoped(); _services.TryAddScoped(); + _services.AddScoped(); } private void AddMiddlewareLayer() diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs deleted file mode 100644 index 17de1485bb..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceIdIsReadOnlyException.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Net; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when trying to change the ID of an existing resource. - /// - public sealed class ResourceIdIsReadOnlyException : JsonApiException - { - public ResourceIdIsReadOnlyException() - : base(new Error(HttpStatusCode.Forbidden) - { - Title = "Resource ID is read-only.", - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs new file mode 100644 index 0000000000..c9f0983871 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Repositories +{ + public interface IDataStoreUpdateFailureInspector + { + Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable leftResource); + + Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, + object secondaryResourceIds); + } + + internal sealed class DataStoreUpdateFailureInspector : IDataStoreUpdateFailureInspector + { + private readonly IResourceContextProvider _resourceContextProvider; + private readonly ITargetedFields _targetedFields; + private readonly IGetResourcesByIds _getResourcesByIds; + + public DataStoreUpdateFailureInspector(IResourceContextProvider resourceContextProvider, + ITargetedFields targetedFields, IGetResourcesByIds getResourcesByIds) + { + _resourceContextProvider = resourceContextProvider ?? + throw new ArgumentNullException(nameof(resourceContextProvider)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _getResourcesByIds = getResourcesByIds ?? throw new ArgumentNullException(nameof(getResourcesByIds)); + } + + public async Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable leftResource) + { + var missingResources = new List(); + + foreach (var relationship in _targetedFields.Relationships) + { + object rightValue = relationship.GetValue(leftResource); + ICollection rightResources = ExtractResources(rightValue); + + var missingResourcesInRelationship = + GetMissingResourcesInRelationshipAsync(relationship, rightResources); + await missingResources.AddRangeAsync(missingResourcesInRelationship); + } + + if (missingResources.Any()) + { + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } + } + + public async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, + object secondaryResourceIds) + { + ICollection rightResources = ExtractResources(secondaryResourceIds); + + var missingResources = + await GetMissingResourcesInRelationshipAsync(relationship, rightResources).ToListAsync(); + + if (missingResources.Any()) + { + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } + } + + private static ICollection ExtractResources(object value) + { + if (value is IEnumerable resources) + { + return resources.ToList(); + } + + if (value is IIdentifiable resource) + { + return new[] {resource}; + } + + return Array.Empty(); + } + + private async IAsyncEnumerable GetMissingResourcesInRelationshipAsync( + RelationshipAttribute relationship, ICollection rightResources) + { + if (rightResources.Any()) + { + var rightIds = rightResources.Select(resource => resource.GetTypedId()).ToHashSet(); + var existingRightResources = await _getResourcesByIds.Get(relationship.RightType, rightIds); + + var existingResourceStringIds = existingRightResources.Select(resource => resource.StringId).ToArray(); + foreach (var rightResource in rightResources) + { + if (!existingResourceStringIds.Contains(rightResource.StringId)) + { + var resourceContext = _resourceContextProvider.GetResourceContext(rightResource.GetType()); + + yield return new MissingResourceInRelationship(relationship.PublicName, + resourceContext.PublicName, rightResource.StringId); + } + } + } + } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index ccb08b9907..3a3eb8aa79 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Humanizer; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -542,7 +543,7 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl } /// - /// Gets the primary resource by id and performs side-loading of data such that EF Core correctly performs complete replacements of relationships. + /// Gets the primary resource by ID and performs side-loading of data such that EF Core correctly performs complete replacements of relationships. /// /// /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. @@ -558,13 +559,13 @@ private async Task GetPrimaryResourceForCompleteReplacement(TId id, I if (relationships.Any()) { - var query = _dbContext.Set().Where(resource => resource.Id.Equals(id)); + IQueryable query = _dbContext.Set(); foreach (var relationship in relationships) { query = query.Include(relationship.RelationshipPath); } - primaryResource = query.FirstOrDefault(); + primaryResource = query.FirstOrDefault(resource => resource.Id.Equals(id)); } else { @@ -573,7 +574,11 @@ private async Task GetPrimaryResourceForCompleteReplacement(TId id, I if (primaryResource == null) { - throw new DataStoreUpdateException($"Resource of type '{typeof(TResource)}' with id '{id}' does not exist."); + var tempResource = _resourceFactory.CreateInstance(); + tempResource.Id = id; + + var resourceContext = _resourceGraph.GetResourceContext(); + throw new ResourceNotFoundException(tempResource.StringId, resourceContext.PublicName); } return primaryResource; diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index e996cfe969..d4f420f412 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Net.Http; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -41,7 +42,19 @@ public object Deserialize(string body) _targetedFields.Relationships.Add(_request.Relationship); } - return DeserializeBody(body); + var instance = DeserializeBody(body); + + AssertResourceIdIsNotTargeted(); + + return instance; + } + + private void AssertResourceIdIsNotTargeted() + { + if (!_request.IsReadOnly && _targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) + { + throw new JsonApiSerializationException("Resource ID is read-only.", null); + } } /// diff --git a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs index 528a612ef5..11157c78b3 100644 --- a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs +++ b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs @@ -16,12 +16,12 @@ namespace JsonApiDotNetCore.Services // TODO: Refactor this type (it is a helper method). public class GetResourcesByIds : IGetResourcesByIds { - private readonly IResourceGraph _resourceGraph; + private readonly IResourceContextProvider _resourceContextProvider; private readonly IResourceRepositoryAccessor _resourceRepositoryAccessor; - public GetResourcesByIds(IResourceGraph resourceGraph, IResourceRepositoryAccessor resourceRepositoryAccessor) + public GetResourcesByIds(IResourceContextProvider resourceContextProvider, IResourceRepositoryAccessor resourceRepositoryAccessor) { - _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _resourceRepositoryAccessor = resourceRepositoryAccessor ?? throw new ArgumentNullException(nameof(resourceRepositoryAccessor)); } @@ -33,7 +33,7 @@ public async Task> Get(Type resourceType, ISe if (typedIds.Any()) { - var resourceContext = _resourceGraph.GetResourceContext(resourceType); + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); var primaryIdProjection = CreatePrimaryIdProjection(resourceContext); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 2c31d6b7bf..7eead9179e 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -23,7 +23,6 @@ public class JsonApiResourceService : where TResource : class, IIdentifiable { private readonly IResourceRepository _repository; - private readonly IGetResourcesByIds _getResourcesByIds; private readonly IQueryLayerComposer _queryLayerComposer; private readonly IPaginationContext _paginationContext; private readonly IJsonApiOptions _options; @@ -31,13 +30,11 @@ public class JsonApiResourceService : private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; private readonly IResourceFactory _resourceFactory; - private readonly ITargetedFields _targetedFields; - private readonly IResourceContextProvider _resourceContextProvider; + private readonly IDataStoreUpdateFailureInspector _dataStoreUpdateFailureInspector; private readonly IResourceHookExecutorFacade _hookExecutor; public JsonApiResourceService( IResourceRepository repository, - IGetResourcesByIds getResourcesByIds, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -45,14 +42,12 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - ITargetedFields targetedFields, - IResourceContextProvider resourceContextProvider, + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, IResourceHookExecutorFacade hookExecutor) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _repository = repository ?? throw new ArgumentNullException(nameof(repository)); - _getResourcesByIds = getResourcesByIds ?? throw new ArgumentNullException(nameof(getResourcesByIds)); _queryLayerComposer = queryLayerComposer ?? throw new ArgumentNullException(nameof(queryLayerComposer)); _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -60,8 +55,7 @@ public JsonApiResourceService( _request = request ?? throw new ArgumentNullException(nameof(request)); _resourceChangeTracker = resourceChangeTracker ?? throw new ArgumentNullException(nameof(resourceChangeTracker)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); - _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); - _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _dataStoreUpdateFailureInspector = dataStoreUpdateFailureInspector ?? throw new ArgumentNullException(nameof(dataStoreUpdateFailureInspector)); _hookExecutor = hookExecutor ?? throw new ArgumentNullException(nameof(hookExecutor)); } @@ -102,7 +96,7 @@ public virtual async Task GetAsync(TId id) _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetSingle); - var primaryResource = await GetPrimaryResourceById(id, TopFieldSelection.PreserveExisting); + var primaryResource = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting); _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle); _hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle); @@ -198,17 +192,17 @@ public virtual async Task CreateAsync(TResource resource) } catch (DataStoreUpdateException) { - var existingResource = await TryGetPrimaryResourceById(resource.Id, TopFieldSelection.OnlyIdAttribute); + var existingResource = await TryGetPrimaryResourceByIdAsync(resource.Id, TopFieldSelection.OnlyIdAttribute); if (existingResource != null) { throw new ResourceAlreadyExistsException(resource.StringId, _request.PrimaryResource.PublicName); } - await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resourceFromRequest); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipsExistAsync(resourceFromRequest); throw; } - var resourceFromDatabase = await GetPrimaryResourceById(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); + var resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); _hookExecutor.AfterCreate(resourceFromDatabase); @@ -233,7 +227,7 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); - + if (secondaryResourceIds.Any()) { try @@ -242,8 +236,8 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, } catch (DataStoreUpdateException) { - await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); + await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; } @@ -256,14 +250,12 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _traceWriter.LogMethodStart(new {id, resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - AssertResourceIdIsNotTargeted(); - var resourceFromRequest = resource; _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); _hookExecutor.BeforeUpdateResource(resourceFromRequest); - TResource resourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.OnlyAllAttributes); + TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyAllAttributes); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); @@ -273,11 +265,11 @@ public virtual async Task UpdateAsync(TId id, TResource resource) } catch (DataStoreUpdateException) { - await AssertRightResourcesInRelationshipsExistAsync(_targetedFields.Relationships, resourceFromRequest); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipsExistAsync(resourceFromRequest); throw; } - TResource afterResourceFromDatabase = await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes); + TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes); _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); @@ -293,14 +285,6 @@ public virtual async Task UpdateAsync(TId id, TResource resource) return afterResourceFromDatabase; } - private void AssertResourceIdIsNotTargeted() - { - if (_targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) - { - throw new ResourceIdIsReadOnlyException(); - } - } - /// public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds) { @@ -310,7 +294,7 @@ public virtual async Task SetRelationshipAsync(TId id, string relationshipName, AssertRelationshipExists(relationshipName); await _hookExecutor.BeforeUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); + async () => await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); try { @@ -318,14 +302,14 @@ await _hookExecutor.BeforeUpdateRelationshipAsync(id, } catch (DataStoreUpdateException) { - await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); - await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); + await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; } await _hookExecutor.AfterUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); + async () => await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); } /// @@ -334,7 +318,7 @@ public virtual async Task DeleteAsync(TId id) _traceWriter.LogMethodStart(new {id}); await _hookExecutor.BeforeDeleteAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); + async () => await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); try { @@ -342,12 +326,12 @@ await _hookExecutor.BeforeDeleteAsync(id, } catch (DataStoreUpdateException) { - await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); throw; } await _hookExecutor.AfterDeleteAsync(id, - async () => await GetPrimaryResourceById(id, TopFieldSelection.WithAllAttributes)); + async () => await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); } /// @@ -366,23 +350,23 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN } catch (DataStoreUpdateException) { - await GetPrimaryResourceById(id, TopFieldSelection.OnlyIdAttribute); + await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); - await AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; } } - private async Task GetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) + private async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection) { - var primaryResource = await TryGetPrimaryResourceById(id, fieldSelection); + var primaryResource = await TryGetPrimaryResourceByIdAsync(id, fieldSelection); AssertPrimaryResourceExists(primaryResource); return primaryResource; } - private async Task TryGetPrimaryResourceById(TId id, TopFieldSelection fieldSelection) + private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection) { var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); primaryLayer.Sort = null; @@ -424,75 +408,6 @@ private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilt : new LogicalExpression(LogicalOperator.And, new[] { filterById, existingFilter }); } - private async Task AssertRightResourcesInRelationshipsExistAsync(IEnumerable relationships, TResource leftResource) - { - var missingResources = new List(); - - foreach (var relationship in relationships) - { - object rightValue = relationship.GetValue(leftResource); - ICollection rightResources = ExtractResources(rightValue); - - var missingResourcesInRelationship = GetMissingResourcesInRelationshipAsync(relationship, rightResources); - await missingResources.AddRangeAsync(missingResourcesInRelationship); - } - - if (missingResources.Any()) - { - throw new ResourcesInRelationshipsNotFoundException(missingResources); - } - } - - private async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, object secondaryResourceIds) - { - ICollection rightResources = ExtractResources(secondaryResourceIds); - - var missingResources = await GetMissingResourcesInRelationshipAsync(relationship, rightResources).ToListAsync(); - if (missingResources.Any()) - { - throw new ResourcesInRelationshipsNotFoundException(missingResources); - } - } - - private async IAsyncEnumerable GetMissingResourcesInRelationshipAsync( - RelationshipAttribute relationship, ICollection rightResources) - { - if (rightResources.Any()) - { - var rightIds = rightResources.Select(resource => resource.GetTypedId()).ToHashSet(); - var existingRightResources = await _getResourcesByIds.Get(relationship.RightType, rightIds); - - var existingResourceStringIds = existingRightResources.Select(resource => resource.StringId).ToArray(); - foreach (var rightResource in rightResources) - { - if (existingResourceStringIds.Contains(rightResource.StringId)) - { - continue; - } - - var resourceContext = _resourceContextProvider.GetResourceContext(rightResource.GetType()); - - yield return new MissingResourceInRelationship(relationship.PublicName, - resourceContext.PublicName, rightResource.StringId); - } - } - } - - private static ICollection ExtractResources(object value) - { - if (value is IEnumerable resources) - { - return resources.ToList(); - } - - if (value is IIdentifiable resource) - { - return new[] {resource}; - } - - return Array.Empty(); - } - private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -549,7 +464,6 @@ public class JsonApiResourceService : JsonApiResourceService repository, - IGetResourcesByIds getResourcesByIds, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -557,11 +471,10 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - ITargetedFields targetedFields, - IResourceContextProvider resourceContextProvider, + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, IResourceHookExecutorFacade hookExecutor) - : base(repository, getResourcesByIds, queryLayerComposer, paginationContext, options, loggerFactory, - request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, hookExecutor) + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, resourceFactory, dataStoreUpdateFailureInspector, hookExecutor) { } } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 44b2a4bf80..5a1592300f 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -42,6 +42,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); } @@ -149,7 +150,6 @@ public class TestModelService : JsonApiResourceService { public TestModelService( IResourceRepository repository, - IGetResourcesByIds getResourcesByIds, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, @@ -157,11 +157,10 @@ public TestModelService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - ITargetedFields targetedFields, - IResourceContextProvider resourceContextProvider, + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, IResourceHookExecutorFacade hookExecutor) - : base(repository, getResourcesByIds, queryLayerComposer, paginationContext, options, loggerFactory, - request, resourceChangeTracker, resourceFactory, targetedFields, resourceContextProvider, + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, resourceFactory, dataStoreUpdateFailureInspector, hookExecutor) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index 5f3dd340d1..c2f4d09562 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -922,12 +922,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - responseDocument.Errors[0].Title.Should().Be("Resource ID is read-only."); - responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource ID is read-only."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource ID is read-only. - Request body: <<"); } [Fact] diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index f4661c2acc..6392e8c85c 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -77,9 +77,10 @@ private JsonApiResourceService GetService() var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; var paginationContext = new PaginationContext(); - var getResourcesByIds = new Mock().Object; var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; + var getResourcesByIds = new Mock().Object; + var dataStoreUpdateFailureInspector = new DataStoreUpdateFailureInspector(resourceContextProvider, targetedFields, getResourcesByIds); var resourceHookExecutor = new NeverResourceHookExecutorFacade(); var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); @@ -91,9 +92,9 @@ private JsonApiResourceService GetService() .Single(x => x.PublicName == "collection") }; - return new JsonApiResourceService(_repositoryMock.Object, getResourcesByIds, composer, - paginationContext, options, NullLoggerFactory.Instance, request, changeTracker, resourceFactory, - targetedFields, resourceContextProvider, resourceHookExecutor); + return new JsonApiResourceService(_repositoryMock.Object, composer, paginationContext, options, + NullLoggerFactory.Instance, request, changeTracker, resourceFactory, dataStoreUpdateFailureInspector, + resourceHookExecutor); } } } From 0d90ee32ccf7a4979870c974b49842e16070b5f4 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Nov 2020 15:43:43 +0100 Subject: [PATCH 04/24] refactor: move logic from GetResourcesByIds to composer --- .../Queries/IQueryLayerComposer.cs | 5 ++ .../Queries/Internal/QueryLayerComposer.cs | 43 +++++++++++++--- .../DataStoreUpdateFailureInspector.cs | 19 ++++--- .../Services/GetResourcesByIds.cs | 50 +++---------------- .../Services/JsonApiResourceService.cs | 19 ++++--- .../Services/DefaultResourceService_Tests.cs | 6 +-- 6 files changed, 74 insertions(+), 68 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index dd2657e6a2..62ac4bb638 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -31,5 +31,10 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc /// IDictionary GetSecondaryProjectionForRelationshipEndpoint( ResourceContext secondaryResourceContext); + + /// + /// Builds a query that filters on the specified IDs and selects them. + /// + QueryLayer ComposeForSecondaryResourceIds(ISet typedIds, ResourceContext resourceContext); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 58d723f13f..b2cee5612f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -194,7 +194,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, return new QueryLayer(primaryResourceContext) { Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), - Filter = IncludeFilterById(primaryId, primaryResourceContext, primaryFilter), + Filter = CreateFilterByIds(new[] {primaryId}, primaryResourceContext, primaryFilter), Projection = primaryProjection }; } @@ -208,16 +208,27 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression r return new IncludeExpression(new[] {parentElement}); } - private FilterExpression IncludeFilterById(TId id, ResourceContext resourceContext, FilterExpression existingFilter) + private FilterExpression CreateFilterByIds(ICollection ids, ResourceContext resourceContext, FilterExpression existingFilter) { var primaryIdAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + var idChain = new ResourceFieldChainExpression(primaryIdAttribute); - FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + FilterExpression filter = null; - return existingFilter == null - ? filterById - : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); + if (ids.Count == 1) + { + var constant = new LiteralConstantExpression(ids.Single().ToString()); + filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + } + else if (ids.Count > 1) + { + var constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList(); + filter = new EqualsAnyOfExpression(idChain, constants); + } + + return filter == null ? existingFilter : + existingFilter == null ? filter : + new LogicalExpression(LogicalOperator.And, new[] {filter, existingFilter}); } public IDictionary GetSecondaryProjectionForRelationshipEndpoint(ResourceContext secondaryResourceContext) @@ -231,6 +242,24 @@ public IDictionary GetSecondaryProjectionFor return secondaryProjection; } + /// + public QueryLayer ComposeForSecondaryResourceIds(ISet typedIds, ResourceContext resourceContext) + { + var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + + var baseFilter = GetFilter(Array.Empty(), resourceContext); + var idsFilter = CreateFilterByIds(typedIds, resourceContext, baseFilter); + + return new QueryLayer(resourceContext) + { + Filter = idsFilter, + Projection = new Dictionary + { + [idAttribute] = null + } + }; + } + protected virtual IReadOnlyCollection GetIncludeElements(IReadOnlyCollection includeElements, ResourceContext resourceContext) { if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs index c9f0983871..17c24ca9f2 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Services; @@ -22,15 +23,17 @@ internal sealed class DataStoreUpdateFailureInspector : IDataStoreUpdateFailureI { private readonly IResourceContextProvider _resourceContextProvider; private readonly ITargetedFields _targetedFields; - private readonly IGetResourcesByIds _getResourcesByIds; + private readonly IQueryLayerComposer _queryLayerComposer; + private readonly IResourceRepositoryAccessor _resourceRepositoryAccessor; public DataStoreUpdateFailureInspector(IResourceContextProvider resourceContextProvider, - ITargetedFields targetedFields, IGetResourcesByIds getResourcesByIds) + ITargetedFields targetedFields, IQueryLayerComposer queryLayerComposer, + IResourceRepositoryAccessor resourceRepositoryAccessor) { - _resourceContextProvider = resourceContextProvider ?? - throw new ArgumentNullException(nameof(resourceContextProvider)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); - _getResourcesByIds = getResourcesByIds ?? throw new ArgumentNullException(nameof(getResourcesByIds)); + _queryLayerComposer = queryLayerComposer ?? throw new ArgumentNullException(nameof(queryLayerComposer)); + _resourceRepositoryAccessor = resourceRepositoryAccessor ?? throw new ArgumentNullException(nameof(resourceRepositoryAccessor)); } public async Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable leftResource) @@ -88,9 +91,13 @@ private async IAsyncEnumerable GetMissingResource if (rightResources.Any()) { var rightIds = rightResources.Select(resource => resource.GetTypedId()).ToHashSet(); - var existingRightResources = await _getResourcesByIds.Get(relationship.RightType, rightIds); + var rightResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); + var queryLayer = _queryLayerComposer.ComposeForSecondaryResourceIds(rightIds, rightResourceContext); + + var existingRightResources = await _resourceRepositoryAccessor.GetAsync(relationship.RightType, queryLayer); var existingResourceStringIds = existingRightResources.Select(resource => resource.StringId).ToArray(); + foreach (var rightResource in rightResources) { if (!existingResourceStringIds.Contains(rightResource.StringId)) diff --git a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs index 11157c78b3..9273b476c2 100644 --- a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs +++ b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs @@ -1,13 +1,10 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Services { @@ -18,11 +15,13 @@ public class GetResourcesByIds : IGetResourcesByIds { private readonly IResourceContextProvider _resourceContextProvider; private readonly IResourceRepositoryAccessor _resourceRepositoryAccessor; + private readonly IQueryLayerComposer _queryLayerComposer; - public GetResourcesByIds(IResourceContextProvider resourceContextProvider, IResourceRepositoryAccessor resourceRepositoryAccessor) + public GetResourcesByIds(IResourceContextProvider resourceContextProvider, IResourceRepositoryAccessor resourceRepositoryAccessor, IQueryLayerComposer queryLayerComposer) { _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _resourceRepositoryAccessor = resourceRepositoryAccessor ?? throw new ArgumentNullException(nameof(resourceRepositoryAccessor)); + _queryLayerComposer = queryLayerComposer ?? throw new ArgumentNullException(nameof(queryLayerComposer)); } /// @@ -31,47 +30,10 @@ public async Task> Get(Type resourceType, ISe if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); if (typedIds == null ) throw new ArgumentNullException(nameof(typedIds)); - if (typedIds.Any()) - { - var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + var queryLayer = _queryLayerComposer.ComposeForSecondaryResourceIds(typedIds, resourceContext); - var primaryIdProjection = CreatePrimaryIdProjection(resourceContext); - - var idValues = typedIds.Select(id => id.ToString()).ToArray(); - var idsFilter = CreateFilterByIds(idValues, resourceContext); - - var queryLayer = new QueryLayer(resourceContext) - { - Projection = primaryIdProjection, - Filter = idsFilter - }; - - return await _resourceRepositoryAccessor.GetAsync(resourceType, queryLayer); - } - - return Array.Empty(); - } - - private Dictionary CreatePrimaryIdProjection(ResourceContext resourceContext) - { - var idAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - var primaryIdProjection = new Dictionary {{idAttribute, null}}; - return primaryIdProjection; - } - - private FilterExpression CreateFilterByIds(ICollection ids, ResourceContext resourceContext) - { - var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - var idChain = new ResourceFieldChainExpression(idAttribute); - - if (ids.Count == 1) - { - var constant = new LiteralConstantExpression(ids.Single()); - return new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); - } - - var constants = ids.Select(id => new LiteralConstantExpression(id)).ToList(); - return new EqualsAnyOfExpression(idChain, constants); + return await _resourceRepositoryAccessor.GetAsync(resourceType, queryLayer); } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 7eead9179e..ba0af93d73 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -344,16 +344,19 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); - try - { - await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); - } - catch (DataStoreUpdateException) + if (secondaryResourceIds.Any()) { - await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + try + { + await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); + } + catch (DataStoreUpdateException) + { + await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); - await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); - throw; + throw; + } } } diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index 6392e8c85c..d4dc46f272 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -76,14 +76,14 @@ private JsonApiResourceService GetService() var serviceProvider = new ServiceContainer(); var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; + var resourceRepositoryAccessor = new Mock().Object; var paginationContext = new PaginationContext(); var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; - var getResourcesByIds = new Mock().Object; - var dataStoreUpdateFailureInspector = new DataStoreUpdateFailureInspector(resourceContextProvider, targetedFields, getResourcesByIds); var resourceHookExecutor = new NeverResourceHookExecutorFacade(); - var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); + var dataStoreUpdateFailureInspector = new DataStoreUpdateFailureInspector(resourceContextProvider, targetedFields, composer, resourceRepositoryAccessor); + var request = new JsonApiRequest { PrimaryResource = _resourceGraph.GetResourceContext(), From a2a6789535a9930ab559bb230d6e59a6883939cc Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Nov 2020 16:31:53 +0100 Subject: [PATCH 05/24] Refactor: remove IGetResourcesByIds --- .../Repositories/DbContextARepository.cs | 5 +- .../Repositories/DbContextBRepository.cs | 5 +- .../JsonApiApplicationBuilder.cs | 2 - .../DataStoreUpdateFailureInspector.cs | 64 +++++++++++-------- .../EntityFrameworkCoreRepository.cs | 27 ++------ .../Services/GetResourcesByIds.cs | 39 ----------- .../Services/IGetResourcesByIds.cs | 22 ------- .../ServiceDiscoveryFacadeTests.cs | 5 +- .../EntityFrameworkCoreRepositoryTests.cs | 7 +- .../CompositeKeys/CarRepository.cs | 5 +- .../ModelStateValidationTests.cs | 1 - .../ResultCapturingRepository.cs | 5 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 7 +- 13 files changed, 62 insertions(+), 132 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Services/GetResourcesByIds.cs delete mode 100644 src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index 14cb6264da..9f13b374f1 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; using MultiDbContextExample.Data; @@ -14,9 +13,9 @@ public class DbContextARepository : EntityFrameworkCoreRepository contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IGetResourcesByIds getResourcesByIds, ILoggerFactory loggerFactory) + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, getResourcesByIds, loggerFactory) + constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) { } } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index c6a59b6883..3346a84d1b 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; using MultiDbContextExample.Data; @@ -14,9 +13,9 @@ public class DbContextBRepository : EntityFrameworkCoreRepository contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IGetResourcesByIds getResourcesByIds, ILoggerFactory loggerFactory) + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, getResourcesByIds, loggerFactory) + constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) { } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index b1f504f4f7..a1b8e455aa 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -192,8 +192,6 @@ private void AddServiceLayer() { RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, typeof(JsonApiResourceService<>), typeof(JsonApiResourceService<,>)); - - _services.AddScoped(); } private void RegisterImplementationForOpenInterfaces(HashSet openGenericInterfaces, Type intImplementation, Type implementation) diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs index 17c24ca9f2..aaa9362f61 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs @@ -14,9 +14,8 @@ namespace JsonApiDotNetCore.Repositories public interface IDataStoreUpdateFailureInspector { Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable leftResource); - - Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, - object secondaryResourceIds); + Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, object secondaryResourceIds); + Task AssertResourcesExist(Type resourceType, ISet resourceIds); } internal sealed class DataStoreUpdateFailureInspector : IDataStoreUpdateFailureInspector @@ -45,8 +44,7 @@ public async Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable le object rightValue = relationship.GetValue(leftResource); ICollection rightResources = ExtractResources(rightValue); - var missingResourcesInRelationship = - GetMissingResourcesInRelationshipAsync(relationship, rightResources); + var missingResourcesInRelationship = GetMissingResourcesAsync(relationship, rightResources); await missingResources.AddRangeAsync(missingResourcesInRelationship); } @@ -61,15 +59,24 @@ public async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttri { ICollection rightResources = ExtractResources(secondaryResourceIds); - var missingResources = - await GetMissingResourcesInRelationshipAsync(relationship, rightResources).ToListAsync(); - + var missingResources = await GetMissingResourcesAsync(relationship, rightResources).ToListAsync(); if (missingResources.Any()) { throw new ResourcesInRelationshipsNotFoundException(missingResources); } } + public async Task AssertResourcesExist(Type resourceType, ISet resourceIds) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + var existingResourceIds = await GetExistingResourceIds(resourceIds, resourceContext); + + if (existingResourceIds.Count < resourceIds.Count) + { + throw new DataStoreUpdateException($"One or more related resources of type '{resourceType}' do not exist."); + } + } + private static ICollection ExtractResources(object value) { if (value is IEnumerable resources) @@ -85,30 +92,35 @@ private static ICollection ExtractResources(object value) return Array.Empty(); } - private async IAsyncEnumerable GetMissingResourcesInRelationshipAsync( - RelationshipAttribute relationship, ICollection rightResources) + private async IAsyncEnumerable GetMissingResourcesAsync(RelationshipAttribute relationship, ICollection rightResources) { - if (rightResources.Any()) - { - var rightIds = rightResources.Select(resource => resource.GetTypedId()).ToHashSet(); - var rightResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); - - var queryLayer = _queryLayerComposer.ComposeForSecondaryResourceIds(rightIds, rightResourceContext); + var rightResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); + var existingResourceIds = await GetExistingResourceIds(rightResources, rightResourceContext); - var existingRightResources = await _resourceRepositoryAccessor.GetAsync(relationship.RightType, queryLayer); - var existingResourceStringIds = existingRightResources.Select(resource => resource.StringId).ToArray(); - - foreach (var rightResource in rightResources) + foreach (var rightResource in rightResources) + { + if (!existingResourceIds.Contains(rightResource.StringId)) { - if (!existingResourceStringIds.Contains(rightResource.StringId)) - { - var resourceContext = _resourceContextProvider.GetResourceContext(rightResource.GetType()); + var resourceContext = _resourceContextProvider.GetResourceContext(rightResource.GetType()); - yield return new MissingResourceInRelationship(relationship.PublicName, - resourceContext.PublicName, rightResource.StringId); - } + yield return new MissingResourceInRelationship(relationship.PublicName, + resourceContext.PublicName, rightResource.StringId); } } } + + private async Task> GetExistingResourceIds(ICollection resourceIds, ResourceContext resourceContext) + { + if (!resourceIds.Any()) + { + return Array.Empty(); + } + + var typedIds = resourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); + var queryLayer = _queryLayerComposer.ComposeForSecondaryResourceIds(typedIds, resourceContext); + + var resources = await _resourceRepositoryAccessor.GetAsync(resourceContext.ResourceType, queryLayer); + return resources.Select(resource => resource.StringId).ToArray(); + } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 3a3eb8aa79..e0a85e0516 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -15,7 +15,6 @@ using JsonApiDotNetCore.Repositories.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Services; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Metadata; @@ -41,9 +40,9 @@ public EntityFrameworkCoreRepository( IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IGetResourcesByIds getResourcesByIds, + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, getResourcesByIds, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) { } } @@ -58,7 +57,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IResourceGraph _resourceGraph; private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; - private readonly IGetResourcesByIds _getResourcesByIds; + private readonly IDataStoreUpdateFailureInspector _dataStoreUpdateFailureInspector; private readonly TraceLogWriter> _traceWriter; public EntityFrameworkCoreRepository(ITargetedFields targetedFields, @@ -66,7 +65,7 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IGetResourcesByIds getResourcesByIds, + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) { if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); @@ -76,7 +75,8 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); - _getResourcesByIds = getResourcesByIds ?? throw new ArgumentNullException(nameof(getResourcesByIds)); + _dataStoreUpdateFailureInspector = dataStoreUpdateFailureInspector ?? throw new ArgumentNullException(nameof(dataStoreUpdateFailureInspector)); + _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -272,7 +272,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); rightResources.ExceptWith(secondaryResourceIds); @@ -584,19 +584,6 @@ private async Task GetPrimaryResourceForCompleteReplacement(TId id, I return primaryResource; } - private async Task AssertSecondaryResourcesExist(ISet secondaryResourceIds, HasManyAttribute relationship) - { - var typedIds = secondaryResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); - var secondaryResourcesFromDatabase = await _getResourcesByIds.Get(relationship.RightType, typedIds); - - if (secondaryResourcesFromDatabase.Count < secondaryResourceIds.Count) - { - throw new DataStoreUpdateException($"One or more related resources of type '{relationship.RightType}' do not exist."); - } - - DetachEntities(secondaryResourcesFromDatabase.ToArray()); - } - private void DetachRelationships(IIdentifiable resource) { foreach (var relationship in _targetedFields.Relationships) diff --git a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs deleted file mode 100644 index 9273b476c2..0000000000 --- a/src/JsonApiDotNetCore/Services/GetResourcesByIds.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Services -{ - // TODO: Reconsider responsibilities (IQueryLayerComposer?) - /// - // TODO: Refactor this type (it is a helper method). - public class GetResourcesByIds : IGetResourcesByIds - { - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IResourceRepositoryAccessor _resourceRepositoryAccessor; - private readonly IQueryLayerComposer _queryLayerComposer; - - public GetResourcesByIds(IResourceContextProvider resourceContextProvider, IResourceRepositoryAccessor resourceRepositoryAccessor, IQueryLayerComposer queryLayerComposer) - { - _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); - _resourceRepositoryAccessor = resourceRepositoryAccessor ?? throw new ArgumentNullException(nameof(resourceRepositoryAccessor)); - _queryLayerComposer = queryLayerComposer ?? throw new ArgumentNullException(nameof(queryLayerComposer)); - } - - /// - public async Task> Get(Type resourceType, ISet typedIds) - { - if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); - if (typedIds == null ) throw new ArgumentNullException(nameof(typedIds)); - - var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - var queryLayer = _queryLayerComposer.ComposeForSecondaryResourceIds(typedIds, resourceContext); - - return await _resourceRepositoryAccessor.GetAsync(resourceType, queryLayer); - } - } -} diff --git a/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs b/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs deleted file mode 100644 index 24828fbc4e..0000000000 --- a/src/JsonApiDotNetCore/Services/IGetResourcesByIds.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Services -{ - /// - /// Gets resources by set of identifiers for a type that is known at runtime. - /// - // TODO: Refactor this type (it is a helper method). - public interface IGetResourcesByIds - { - /// - /// Retrieves resources of type where the identifiers match . - /// - /// The resource type to get. - /// The identifiers of the resources to get. - /// - Task> Get(Type resourceType, ISet typedIds); - } -} diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 5a1592300f..b7c17551e8 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -41,7 +41,6 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); @@ -174,9 +173,9 @@ public TestModelRepository( IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IGetResourcesByIds getResourcesByIds, + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, getResourcesByIds, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) { } } diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index 5fac2caabe..089bc1625e 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -9,7 +9,6 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; @@ -89,10 +88,12 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri contextResolverMock.Setup(m => m.GetContext()).Returns(context); var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); var targetedFields = new Mock(); - var getResourcesByIds = new Mock().Object; + var dataStoreUpdateFailureInspector = new Mock().Object; + var repository = new EntityFrameworkCoreRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, resourceFactory, new List(), - getResourcesByIds, NullLoggerFactory.Instance); + dataStoreUpdateFailureInspector, NullLoggerFactory.Instance); + return (repository, targetedFields, resourceGraph); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs index 671164b31f..cdce60d150 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs @@ -5,7 +5,6 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys @@ -16,9 +15,9 @@ public sealed class CarRepository : EntityFrameworkCoreRepository public CarRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, - IEnumerable constraintProviders, IGetResourcesByIds getResourcesByIds, + IEnumerable constraintProviders, IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, getResourcesByIds, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) { _resourceGraph = resourceGraph; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index a4209a5cf3..326e348ea9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs index d4668e0f9c..9efd0258c4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -4,7 +4,6 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets @@ -24,10 +23,10 @@ public ResultCapturingRepository( IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IGetResourcesByIds getResourcesByIds, + IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ResourceCaptureStore captureStore) : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, getResourcesByIds, loggerFactory) + constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) { _captureStore = captureStore; } diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 38028158cb..bff6fe1a55 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -13,7 +13,6 @@ using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; @@ -369,10 +368,10 @@ private IResourceReadRepository CreateTestRepository(AppDbC var resourceFactory = new ResourceFactory(serviceProvider); IDbContextResolver resolver = CreateTestDbResolver(dbContext); var targetedFields = new TargetedFields(); - var getResourcesByIds = new Mock().Object; + var dataStoreUpdateFailureInspector = new Mock().Object; - return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, - resourceFactory, new List(), getResourcesByIds, NullLoggerFactory.Instance); + return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, resourceFactory, + new List(), dataStoreUpdateFailureInspector, NullLoggerFactory.Instance); } private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) From 1c7cf6abdcf4281fff16bf923c9a8606e091ec39 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Nov 2020 18:05:16 +0100 Subject: [PATCH 06/24] Replace repo.GetPrimaryResourceForCompleteReplacement with overridable coordination from service. --- .../Services/WorkItemService.cs | 6 +- .../Internal/IResourceHookExecutorFacade.cs | 8 +- .../NeverResourceHookExecutorFacade.cs | 10 +-- .../Internal/ResourceHookExecutorFacade.cs | 10 +-- .../Queries/IQueryLayerComposer.cs | 16 ++-- .../Queries/Internal/QueryLayerComposer.cs | 46 ++++++++-- .../DataStoreUpdateFailureInspector.cs | 32 ++----- .../EntityFrameworkCoreRepository.cs | 85 +++++------------- .../Repositories/IResourceWriteRepository.cs | 19 ++-- .../Services/IAddToRelationshipService.cs | 4 +- .../IRemoveFromRelationshipService.cs | 4 +- .../Services/ISetRelationshipService.cs | 4 +- .../Services/JsonApiResourceService.cs | 89 +++++++++---------- src/JsonApiDotNetCore/TypeHelper.cs | 17 +++- .../EntityFrameworkCoreRepositoryTests.cs | 2 +- .../IServiceCollectionExtensionsTests.cs | 12 +-- .../Services/DefaultResourceService_Tests.cs | 2 +- 17 files changed, 180 insertions(+), 186 deletions(-) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 5ee86f38ae..a5c34e0966 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -61,7 +61,7 @@ public async Task CreateAsync(WorkItem resource) })).SingleOrDefault(); } - public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) + public Task AddToToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) { throw new NotImplementedException(); } @@ -71,7 +71,7 @@ public Task UpdateAsync(int id, WorkItem resource) throw new NotImplementedException(); } - public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) + public Task SetRelationshipAsync(int primaryId, string relationshipName, object secondaryResourceIds) { throw new NotImplementedException(); } @@ -82,7 +82,7 @@ await QueryAsync(async connection => await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new {id})); } - public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) + public Task RemoveFromToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs index e553b7948c..5e488e562c 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutorFacade.cs @@ -35,11 +35,11 @@ void BeforeUpdateResource(TResource resource) void AfterUpdateResource(TResource resource) where TResource : class, IIdentifiable; - Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) - where TResource : class, IIdentifiable; + void BeforeUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable; - Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) - where TResource : class, IIdentifiable; + void AfterUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable; Task BeforeDeleteAsync(TId id, Func> getResourceAsync) where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs index beb93a151a..a849a4f736 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs @@ -52,16 +52,14 @@ public void AfterUpdateResource(TResource resource) { } - public Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) - where TResource : class, IIdentifiable + public void BeforeUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable { - return Task.CompletedTask; } - public Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) - where TResource : class, IIdentifiable + public void AfterUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable { - return Task.CompletedTask; } public Task BeforeDeleteAsync(TId id, Func> getResourceAsync) diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs index faf6cc2995..362c186bf6 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs @@ -76,17 +76,15 @@ public void AfterUpdateResource(TResource resource) _resourceHookExecutor.AfterUpdate(ToList(resource), ResourcePipeline.Patch); } - public async Task BeforeUpdateRelationshipAsync(TId id, Func> getResourceAsync) - where TResource : class, IIdentifiable + public void BeforeUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable { - var resource = await getResourceAsync(); _resourceHookExecutor.BeforeUpdate(ToList(resource), ResourcePipeline.PatchRelationship); } - public async Task AfterUpdateRelationshipAsync(TId id, Func> getResourceAsync) - where TResource : class, IIdentifiable + public void AfterUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable { - var resource = await getResourceAsync(); _resourceHookExecutor.AfterUpdate(ToList(resource), ResourcePipeline.PatchRelationship); } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index 62ac4bb638..fbf5e59fd2 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -13,12 +13,12 @@ public interface IQueryLayerComposer /// /// Builds a top-level filter from constraints, used to determine total resource count. /// - FilterExpression GetTopFilter(); + FilterExpression GetTopFilterFromConstraints(); /// /// Collects constraints and builds a out of them, used to retrieve the actual resources. /// - QueryLayer Compose(ResourceContext requestResource); + QueryLayer ComposeFromConstraints(ResourceContext requestResource); /// /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. @@ -27,14 +27,18 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc TId primaryId, RelationshipAttribute secondaryRelationship); /// - /// Gets the secondary projection for a relationship endpoint. + /// Collects constraints and builds the secondary layer for a relationship endpoint. /// - IDictionary GetSecondaryProjectionForRelationshipEndpoint( - ResourceContext secondaryResourceContext); + QueryLayer ComposeLayerForRelationship(ResourceContext secondaryResourceContext); /// /// Builds a query that filters on the specified IDs and selects them. /// - QueryLayer ComposeForSecondaryResourceIds(ISet typedIds, ResourceContext resourceContext); + QueryLayer ComposeForFilterOnResourceIds(ISet typedIds, ResourceContext resourceContext); + + /// + /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete request. + /// + QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index b2cee5612f..589c407f35 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -16,23 +16,26 @@ public class QueryLayerComposer : IQueryLayerComposer private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IJsonApiOptions _options; private readonly IPaginationContext _paginationContext; + private readonly ITargetedFields _targetedFields; public QueryLayerComposer( IEnumerable constraintProviders, IResourceContextProvider resourceContextProvider, IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiOptions options, - IPaginationContext paginationContext) + IPaginationContext paginationContext, + ITargetedFields targetedFields) { _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); _options = options ?? throw new ArgumentNullException(nameof(options)); _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); } /// - public FilterExpression GetTopFilter() + public FilterExpression GetTopFilterFromConstraints() { var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); @@ -56,7 +59,7 @@ public FilterExpression GetTopFilter() } /// - public QueryLayer Compose(ResourceContext requestResource) + public QueryLayer ComposeFromConstraints(ResourceContext requestResource) { if (requestResource == null) { @@ -231,20 +234,32 @@ private FilterExpression CreateFilterByIds(ICollection ids, ResourceCo new LogicalExpression(LogicalOperator.And, new[] {filter, existingFilter}); } - public IDictionary GetSecondaryProjectionForRelationshipEndpoint(ResourceContext secondaryResourceContext) + public QueryLayer ComposeLayerForRelationship(ResourceContext secondaryResourceContext) + { + var secondaryLayer = ComposeFromConstraints(secondaryResourceContext); + secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceContext); + secondaryLayer.Include = null; + + return secondaryLayer; + } + + private IDictionary GetProjectionForRelationship(ResourceContext secondaryResourceContext) { var secondaryIdAttribute = secondaryResourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - var sparseFieldSet = new SparseFieldSetExpression(new[] { secondaryIdAttribute }); + var sparseFieldSet = new SparseFieldSetExpression(new[] {secondaryIdAttribute}); - var secondaryProjection = GetSparseFieldSetProjection(new[] { sparseFieldSet }, secondaryResourceContext) ?? new Dictionary(); + var secondaryProjection = GetSparseFieldSetProjection(new[] {sparseFieldSet}, secondaryResourceContext) ?? new Dictionary(); secondaryProjection[secondaryIdAttribute] = null; return secondaryProjection; } /// - public QueryLayer ComposeForSecondaryResourceIds(ISet typedIds, ResourceContext resourceContext) + public QueryLayer ComposeForFilterOnResourceIds(ISet typedIds, ResourceContext resourceContext) { + if (typedIds == null) throw new ArgumentNullException(nameof(typedIds)); + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); + var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); var baseFilter = GetFilter(Array.Empty(), resourceContext); @@ -260,6 +275,23 @@ public QueryLayer ComposeForSecondaryResourceIds(ISet typedIds, Resource }; } + public QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource) + { + var primaryLayer = ComposeTopLayer(Array.Empty(), primaryResource); + + primaryLayer.Filter = CreateFilterByIds(new[] {id}, primaryResource, primaryLayer.Filter); + + var includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)).ToArray(); + + if (includeElements.Any()) + { + primaryLayer.Include = new IncludeExpression(includeElements); + } + + return primaryLayer; + } + protected virtual IReadOnlyCollection GetIncludeElements(IReadOnlyCollection includeElements, ResourceContext resourceContext) { if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs index aaa9362f61..c70d5ded3e 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs @@ -14,7 +14,7 @@ namespace JsonApiDotNetCore.Repositories public interface IDataStoreUpdateFailureInspector { Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable leftResource); - Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, object secondaryResourceIds); + Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, ICollection rightResourceIds); Task AssertResourcesExist(Type resourceType, ISet resourceIds); } @@ -42,9 +42,9 @@ public async Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable le foreach (var relationship in _targetedFields.Relationships) { object rightValue = relationship.GetValue(leftResource); - ICollection rightResources = ExtractResources(rightValue); + ICollection rightResources = TypeHelper.ExtractResources(rightValue); - var missingResourcesInRelationship = GetMissingResourcesAsync(relationship, rightResources); + var missingResourcesInRelationship = GetMissingRightResourcesAsync(rightResources, relationship); await missingResources.AddRangeAsync(missingResourcesInRelationship); } @@ -55,11 +55,9 @@ public async Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable le } public async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, - object secondaryResourceIds) + ICollection rightResourceIds) { - ICollection rightResources = ExtractResources(secondaryResourceIds); - - var missingResources = await GetMissingResourcesAsync(relationship, rightResources).ToListAsync(); + var missingResources = await GetMissingRightResourcesAsync(rightResourceIds, relationship).ToListAsync(); if (missingResources.Any()) { throw new ResourcesInRelationshipsNotFoundException(missingResources); @@ -77,22 +75,8 @@ public async Task AssertResourcesExist(Type resourceType, ISet re } } - private static ICollection ExtractResources(object value) - { - if (value is IEnumerable resources) - { - return resources.ToList(); - } - - if (value is IIdentifiable resource) - { - return new[] {resource}; - } - - return Array.Empty(); - } - - private async IAsyncEnumerable GetMissingResourcesAsync(RelationshipAttribute relationship, ICollection rightResources) + private async IAsyncEnumerable GetMissingRightResourcesAsync( + ICollection rightResources, RelationshipAttribute relationship) { var rightResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); var existingResourceIds = await GetExistingResourceIds(rightResources, rightResourceContext); @@ -117,7 +101,7 @@ private async Task> GetExistingResourceIds(ICollection resource.GetTypedId()).ToHashSet(); - var queryLayer = _queryLayerComposer.ComposeForSecondaryResourceIds(typedIds, resourceContext); + var queryLayer = _queryLayerComposer.ComposeForFilterOnResourceIds(typedIds, resourceContext); var resources = await _resourceRepositoryAccessor.GetAsync(resourceContext.ResourceType, queryLayer); return resources.Select(resource => resource.StringId).ToArray(); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index e0a85e0516..bfd5d88d03 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -7,7 +7,6 @@ using System.Threading.Tasks; using Humanizer; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -163,9 +162,9 @@ public virtual async Task CreateAsync(TResource resource) } /// - public virtual async Task AddToToManyRelationshipAsync(TId id, ISet secondaryResourceIds) + public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); + _traceWriter.LogMethodStart(new {primaryId, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); var relationship = _targetedFields.Relationships.Single(); @@ -173,10 +172,10 @@ public virtual async Task AddToToManyRelationshipAsync(TId id, ISet - public virtual async Task SetRelationshipAsync(TId id, object secondaryResourceIds) + public virtual async Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); + _traceWriter.LogMethodStart(new {primaryResource, secondaryResourceIds}); - var primaryResource = await GetPrimaryResourceForCompleteReplacement(id, _targetedFields.Relationships); - var relationship = _targetedFields.Relationships.Single(); await ApplyRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); - + await SaveChangesAsync(); } /// - public virtual async Task UpdateAsync(TResource resource) + public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase) { - _traceWriter.LogMethodStart(new {resource}); - if (resource == null) throw new ArgumentNullException(nameof(resource)); - - var resourceFromDatabase = await GetPrimaryResourceForCompleteReplacement(resource.Id, _targetedFields.Relationships); + _traceWriter.LogMethodStart(new {resourceFromRequest, resourceFromDatabase}); + if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); + if (resourceFromDatabase == null) throw new ArgumentNullException(nameof(resourceFromDatabase)); foreach (var relationship in _targetedFields.Relationships) { - var rightResources = relationship.GetValue(resource); + var rightResources = relationship.GetValue(resourceFromRequest); await ApplyRelationshipUpdate(relationship, resourceFromDatabase, rightResources); } foreach (var attribute in _targetedFields.Attributes) { - attribute.SetValue(resourceFromDatabase, attribute.GetValue(resource)); + attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); } await SaveChangesAsync(); @@ -264,12 +260,10 @@ private INavigation GetNavigationMetadata(RelationshipAttribute relationship) } /// - public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet secondaryResourceIds) + public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, secondaryResourceIds}); + _traceWriter.LogMethodStart(new {primaryResource, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); - - var primaryResource = await GetPrimaryResourceForCompleteReplacement(id, _targetedFields.Relationships); var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); await _dataStoreUpdateFailureInspector.AssertResourcesExist(relationship.RightType, secondaryResourceIds); @@ -281,6 +275,13 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId id, ISet + public virtual async Task TryGetPrimaryResourceForUpdateAsync(QueryLayer queryLayer) + { + var resources = await GetAsync(queryLayer); + return resources.FirstOrDefault(); + } + private async Task SaveChangesAsync() { try @@ -542,48 +543,6 @@ private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyColl return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); } - /// - /// Gets the primary resource by ID and performs side-loading of data such that EF Core correctly performs complete replacements of relationships. - /// - /// - /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. - /// If we want to update this set to `t3` and `t4`, simply assigning - /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, - /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, - /// after which the reassignment `p1.todoItems = [t3, t4]` will actually - /// make EF Core perform a complete replacement. This method does the loading of `[t1, t2]`. - /// - private async Task GetPrimaryResourceForCompleteReplacement(TId id, ISet relationships) - { - TResource primaryResource; - - if (relationships.Any()) - { - IQueryable query = _dbContext.Set(); - foreach (var relationship in relationships) - { - query = query.Include(relationship.RelationshipPath); - } - - primaryResource = query.FirstOrDefault(resource => resource.Id.Equals(id)); - } - else - { - primaryResource = await _dbContext.FindAsync(id); - } - - if (primaryResource == null) - { - var tempResource = _resourceFactory.CreateInstance(); - tempResource.Id = id; - - var resourceContext = _resourceGraph.GetResourceContext(); - throw new ResourceNotFoundException(tempResource.StringId, resourceContext.PublicName); - } - - return primaryResource; - } - private void DetachRelationships(IIdentifiable resource) { foreach (var relationship in _targetedFields.Relationships) diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index db6fa3a8c5..31e10e022f 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; using System.Threading.Tasks; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.Repositories { /// - public interface IResourceWriteRepository + public interface IResourceWriteRepository : IResourceWriteRepository where TResource : class, IIdentifiable { } @@ -15,7 +16,7 @@ public interface IResourceWriteRepository /// /// The resource type. /// The resource identifier type. - public interface IResourceWriteRepository + public interface IResourceWriteRepository where TResource : class, IIdentifiable { /// @@ -26,17 +27,17 @@ public interface IResourceWriteRepository /// /// Adds resources to a to-many relationship in the underlying data store. /// - Task AddToToManyRelationshipAsync(TId id, ISet secondaryResourceIds); + Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds); /// /// Updates the attributes and relationships of an existing resource in the underlying data store. /// - Task UpdateAsync(TResource resource); + Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase); /// /// Performs a complete replacement of the relationship in the underlying data store. /// - Task SetRelationshipAsync(TId id, object secondaryResourceIds); + Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds); /// /// Deletes an existing resource from the underlying data store. @@ -46,6 +47,12 @@ public interface IResourceWriteRepository /// /// Removes resources from a to-many relationship in the underlying data store. /// - Task RemoveFromToManyRelationshipAsync(TId id, ISet secondaryResourceIds); + Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds); + + /// + /// Attempts to retrieve the primary resource during a create/update/delete request. + /// + /// + Task TryGetPrimaryResourceForUpdateAsync(QueryLayer queryLayer); } } diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs index 480571c846..01a37e726e 100644 --- a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -16,9 +16,9 @@ public interface IAddToRelationshipService /// /// Handles a json:api request to add resources to a to-many relationship. /// - /// The identifier of the primary resource. + /// The identifier of the primary resource. /// The relationship to add resources to. /// The set of resources to add to the relationship. - Task AddToToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds); + Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index 9511b6583c..34c39608e6 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -16,9 +16,9 @@ public interface IRemoveFromRelationshipService /// /// Handles a json:api request to remove resources from a to-many relationship. /// - /// The identifier of the primary resource. + /// The identifier of the primary resource. /// The relationship to remove resources from. /// The set of resources to remove from the relationship. - Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds); + Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 2db24d4992..75bfc2722e 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -15,9 +15,9 @@ public interface ISetRelationshipService /// /// Handles a json:api request to perform a complete replacement of a relationship on an existing resource. /// - /// The identifier of the primary resource. + /// The identifier of the primary resource. /// The relationship for which to perform a complete replacement. /// The resource or set of resources to assign to the relationship. - Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds); + Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index ba0af93d73..b5e5aa550f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -68,7 +68,7 @@ public virtual async Task> GetAsync() if (_options.IncludeTotalResourceCount) { - var topFilter = _queryLayerComposer.GetTopFilter(); + var topFilter = _queryLayerComposer.GetTopFilterFromConstraints(); _paginationContext.TotalResourceCount = await _repository.CountAsync(topFilter); if (_paginationContext.TotalResourceCount == 0) @@ -77,7 +77,7 @@ public virtual async Task> GetAsync() } } - var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + var queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResource); var resources = await _repository.GetAsync(queryLayer); if (queryLayer.Pagination?.PageSize != null && queryLayer.Pagination.PageSize.Value == resources.Count) @@ -96,7 +96,7 @@ public virtual async Task GetAsync(TId id) _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetSingle); - var primaryResource = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting); + var primaryResource = await GetPrimaryResourceForReadAsync(id, TopFieldSelection.PreserveExisting); _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle); _hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle); @@ -112,7 +112,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); + var secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResource); var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); if (_request.IsCollection && _options.IncludeTotalResourceCount) @@ -152,10 +152,7 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); - secondaryLayer.Include = null; - + var secondaryLayer = _queryLayerComposer.ComposeLayerForRelationship(_request.SecondaryResource); var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); var primaryResources = await _repository.GetAsync(primaryLayer); @@ -192,7 +189,7 @@ public virtual async Task CreateAsync(TResource resource) } catch (DataStoreUpdateException) { - var existingResource = await TryGetPrimaryResourceByIdAsync(resource.Id, TopFieldSelection.OnlyIdAttribute); + var existingResource = await TryGetPrimaryResourceForReadAsync(resource.Id, TopFieldSelection.OnlyIdAttribute); if (existingResource != null) { throw new ResourceAlreadyExistsException(resource.StringId, _request.PrimaryResource.PublicName); @@ -202,7 +199,7 @@ public virtual async Task CreateAsync(TResource resource) throw; } - var resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); + var resourceFromDatabase = await GetPrimaryResourceForReadAsync(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); _hookExecutor.AfterCreate(resourceFromDatabase); @@ -219,9 +216,9 @@ public virtual async Task CreateAsync(TResource resource) } /// - public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds) + public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new { id, secondaryResourceIds }); + _traceWriter.LogMethodStart(new { primaryId, secondaryResourceIds }); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); @@ -232,11 +229,11 @@ public async Task AddToToManyRelationshipAsync(TId id, string relationshipName, { try { - await _repository.AddToToManyRelationshipAsync(id, secondaryResourceIds); + await _repository.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds); } catch (DataStoreUpdateException) { - await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + await GetPrimaryResourceForReadAsync(primaryId, TopFieldSelection.OnlyIdAttribute); await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; @@ -255,13 +252,13 @@ public virtual async Task UpdateAsync(TId id, TResource resource) _hookExecutor.BeforeUpdateResource(resourceFromRequest); - TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyAllAttributes); + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); try { - await _repository.UpdateAsync(resourceFromRequest); + await _repository.UpdateAsync(resourceFromRequest, resourceFromDatabase); } catch (DataStoreUpdateException) { @@ -269,7 +266,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) throw; } - TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes); + TResource afterResourceFromDatabase = await GetPrimaryResourceForReadAsync(id, TopFieldSelection.WithAllAttributes); _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); @@ -286,30 +283,29 @@ public virtual async Task UpdateAsync(TId id, TResource resource) } /// - public virtual async Task SetRelationshipAsync(TId id, string relationshipName, object secondaryResourceIds) + public virtual async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); + _traceWriter.LogMethodStart(new {primaryId, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); - await _hookExecutor.BeforeUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId); + + _hookExecutor.BeforeUpdateRelationshipAsync(resourceFromDatabase); try { - await _repository.SetRelationshipAsync(id, secondaryResourceIds); + await _repository.SetRelationshipAsync(resourceFromDatabase, secondaryResourceIds); } catch (DataStoreUpdateException) { - await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); - await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, TypeHelper.ExtractResources(secondaryResourceIds)); throw; } - await _hookExecutor.AfterUpdateRelationshipAsync(id, - async () => await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); + _hookExecutor.AfterUpdateRelationshipAsync(resourceFromDatabase); } /// @@ -318,7 +314,7 @@ public virtual async Task DeleteAsync(TId id) _traceWriter.LogMethodStart(new {id}); await _hookExecutor.BeforeDeleteAsync(id, - async () => await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); + async () => await GetPrimaryResourceForReadAsync(id, TopFieldSelection.WithAllAttributes)); try { @@ -326,33 +322,35 @@ await _hookExecutor.BeforeDeleteAsync(id, } catch (DataStoreUpdateException) { - await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + await GetPrimaryResourceForReadAsync(id, TopFieldSelection.OnlyIdAttribute); throw; } await _hookExecutor.AfterDeleteAsync(id, - async () => await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); + async () => await GetPrimaryResourceForReadAsync(id, TopFieldSelection.WithAllAttributes)); } /// - public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipName, ISet secondaryResourceIds) + public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, secondaryResourceIds}); + _traceWriter.LogMethodStart(new {primaryId, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); AssertRelationshipExists(relationshipName); AssertRelationshipIsToMany(); + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId); + if (secondaryResourceIds.Any()) { try { - await _repository.RemoveFromToManyRelationshipAsync(id, secondaryResourceIds); + await _repository.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds); } catch (DataStoreUpdateException) { - await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + await GetPrimaryResourceForReadAsync(primaryId, TopFieldSelection.OnlyIdAttribute); await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; @@ -360,18 +358,18 @@ public async Task RemoveFromToManyRelationshipAsync(TId id, string relationshipN } } - private async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection) + private async Task GetPrimaryResourceForReadAsync(TId id, TopFieldSelection fieldSelection) { - var primaryResource = await TryGetPrimaryResourceByIdAsync(id, fieldSelection); + var primaryResource = await TryGetPrimaryResourceForReadAsync(id, fieldSelection); AssertPrimaryResourceExists(primaryResource); return primaryResource; } - private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection) + private async Task TryGetPrimaryResourceForReadAsync(TId id, TopFieldSelection fieldSelection) { - var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + var primaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResource); primaryLayer.Sort = null; primaryLayer.Pagination = null; primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); @@ -389,16 +387,19 @@ private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSel primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); } } - else if (fieldSelection == TopFieldSelection.OnlyAllAttributes) - { - primaryLayer.Include = null; - primaryLayer.Projection = null; - } var primaryResources = await _repository.GetAsync(primaryLayer); return primaryResources.SingleOrDefault(); } + private async Task GetPrimaryResourceForUpdateAsync(TId id) + { + var queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); + var resourceFromDatabase = await _repository.TryGetPrimaryResourceForUpdateAsync(queryLayer); + AssertPrimaryResourceExists(resourceFromDatabase); + return resourceFromDatabase; + } + private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) { var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); @@ -438,10 +439,6 @@ private void AssertRelationshipIsToMany() private enum TopFieldSelection { - /// - /// Discards any included relationships and selects all resource attributes. - /// - OnlyAllAttributes, /// /// Preserves included relationships, but selects all resource attributes. /// diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 8c3adeb83d..0fe351e652 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -92,7 +92,7 @@ public static bool CanContainNull(Type type) return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } - internal static object GetDefaultValue(Type type) + public static object GetDefaultValue(Type type) { return type.IsValueType ? CreateInstance(type) : null; } @@ -266,6 +266,21 @@ public static Type GetIdType(Type resourceType) return property.PropertyType; } + public static ICollection ExtractResources(object value) + { + if (value is IEnumerable resources) + { + return resources.ToList(); + } + + if (value is IIdentifiable resource) + { + return new[] {resource}; + } + + return Array.Empty(); + } + public static object CreateInstance(Type type) { if (type == null) diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index 089bc1625e..d4c582f63e 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -55,7 +55,7 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri targetedFields.Setup(m => m.Relationships).Returns(new HashSet()); // Act - await repository.UpdateAsync(todoItemUpdates); + await repository.UpdateAsync(todoItemUpdates, databaseResource); } // Assert - in different context diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index bf7694f033..6051f8db33 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -164,11 +164,11 @@ private class IntResourceService : IResourceService public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); public Task CreateAsync(IntResource resource) => throw new NotImplementedException(); - public Task AddToToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); public Task UpdateAsync(int id, IntResource resource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(int id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int primaryId, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); public Task DeleteAsync(int id) => throw new NotImplementedException(); - public Task RemoveFromToManyRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } private class GuidResourceService : IResourceService @@ -178,11 +178,11 @@ private class GuidResourceService : IResourceService public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); public Task CreateAsync(GuidResource resource) => throw new NotImplementedException(); - public Task AddToToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); public Task UpdateAsync(Guid id, GuidResource resource) => throw new NotImplementedException(); - public Task SetRelationshipAsync(Guid id, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid primaryId, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); public Task DeleteAsync(Guid id) => throw new NotImplementedException(); - public Task RemoveFromToManyRelationshipAsync(Guid id, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(Guid primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } public class TestContext : DbContext diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index d4dc46f272..8e3b4dd125 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -81,7 +81,7 @@ private JsonApiResourceService GetService() var targetedFields = new Mock().Object; var resourceContextProvider = new Mock().Object; var resourceHookExecutor = new NeverResourceHookExecutorFacade(); - var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); + var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext, targetedFields); var dataStoreUpdateFailureInspector = new DataStoreUpdateFailureInspector(resourceContextProvider, targetedFields, composer, resourceRepositoryAccessor); var request = new JsonApiRequest From 5dc95bba00fc000fd810b64ca7867acf04ada93c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Nov 2020 18:18:46 +0100 Subject: [PATCH 07/24] Removed IDataStoreUpdateFailureInspector dependency from repository --- .../Repositories/DbContextARepository.cs | 7 +++---- .../Repositories/DbContextBRepository.cs | 7 +++---- .../Repositories/DataStoreUpdateFailureInspector.cs | 12 ------------ .../Repositories/EntityFrameworkCoreRepository.cs | 7 +------ .../Services/JsonApiResourceService.cs | 13 ++----------- test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs | 3 +-- .../Data/EntityFrameworkCoreRepositoryTests.cs | 4 +--- .../IntegrationTests/CompositeKeys/CarRepository.cs | 5 ++--- .../SparseFieldSets/ResultCapturingRepository.cs | 4 +--- .../ResourceHooks/ResourceHooksTestsSetup.cs | 3 +-- 10 files changed, 15 insertions(+), 50 deletions(-) diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index 9f13b374f1..574695700b 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -12,10 +12,9 @@ public class DbContextARepository : EntityFrameworkCoreRepository { public DbContextARepository(ITargetedFields targetedFields, DbContextResolver contextResolver, - IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) + IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index 3346a84d1b..098a580579 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -12,10 +12,9 @@ public class DbContextBRepository : EntityFrameworkCoreRepository { public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver contextResolver, - IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) + IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs index c70d5ded3e..789a1b3177 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs @@ -15,7 +15,6 @@ public interface IDataStoreUpdateFailureInspector { Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable leftResource); Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, ICollection rightResourceIds); - Task AssertResourcesExist(Type resourceType, ISet resourceIds); } internal sealed class DataStoreUpdateFailureInspector : IDataStoreUpdateFailureInspector @@ -64,17 +63,6 @@ public async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttri } } - public async Task AssertResourcesExist(Type resourceType, ISet resourceIds) - { - var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - var existingResourceIds = await GetExistingResourceIds(resourceIds, resourceContext); - - if (existingResourceIds.Count < resourceIds.Count) - { - throw new DataStoreUpdateException($"One or more related resources of type '{resourceType}' do not exist."); - } - } - private async IAsyncEnumerable GetMissingRightResourcesAsync( ICollection rightResources, RelationshipAttribute relationship) { diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index bfd5d88d03..cc9ba06c3f 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -39,9 +39,8 @@ public EntityFrameworkCoreRepository( IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } @@ -56,7 +55,6 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IResourceGraph _resourceGraph; private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; - private readonly IDataStoreUpdateFailureInspector _dataStoreUpdateFailureInspector; private readonly TraceLogWriter> _traceWriter; public EntityFrameworkCoreRepository(ITargetedFields targetedFields, @@ -64,7 +62,6 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) { if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); @@ -74,7 +71,6 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); - _dataStoreUpdateFailureInspector = dataStoreUpdateFailureInspector ?? throw new ArgumentNullException(nameof(dataStoreUpdateFailureInspector)); _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); @@ -266,7 +262,6 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryRes if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); - await _dataStoreUpdateFailureInspector.AssertResourcesExist(relationship.RightType, secondaryResourceIds); var rightResources = ((IEnumerable)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); rightResources.ExceptWith(secondaryResourceIds); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index b5e5aa550f..15aaa59748 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -341,20 +341,11 @@ public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relati AssertRelationshipIsToMany(); TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); if (secondaryResourceIds.Any()) { - try - { - await _repository.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds); - } - catch (DataStoreUpdateException) - { - await GetPrimaryResourceForReadAsync(primaryId, TopFieldSelection.OnlyIdAttribute); - await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); - - throw; - } + await _repository.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds); } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index b7c17551e8..0c9a24be6a 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -173,9 +173,8 @@ public TestModelRepository( IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index d4c582f63e..fe4121469e 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -88,11 +88,9 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri contextResolverMock.Setup(m => m.GetContext()).Returns(context); var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); var targetedFields = new Mock(); - var dataStoreUpdateFailureInspector = new Mock().Object; var repository = new EntityFrameworkCoreRepository(targetedFields.Object, - contextResolverMock.Object, resourceGraph, resourceFactory, new List(), - dataStoreUpdateFailureInspector, NullLoggerFactory.Instance); + contextResolverMock.Object, resourceGraph, resourceFactory, new List(), NullLoggerFactory.Instance); return (repository, targetedFields, resourceGraph); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs index cdce60d150..d266ae4ae1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs @@ -15,9 +15,8 @@ public sealed class CarRepository : EntityFrameworkCoreRepository public CarRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, - IEnumerable constraintProviders, IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { _resourceGraph = resourceGraph; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs index 9efd0258c4..9d6a522ae1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -23,10 +23,8 @@ public ResultCapturingRepository( IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, ResourceCaptureStore captureStore) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, - constraintProviders, dataStoreUpdateFailureInspector, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { _captureStore = captureStore; } diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index bff6fe1a55..017e61fcb1 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -368,10 +368,9 @@ private IResourceReadRepository CreateTestRepository(AppDbC var resourceFactory = new ResourceFactory(serviceProvider); IDbContextResolver resolver = CreateTestDbResolver(dbContext); var targetedFields = new TargetedFields(); - var dataStoreUpdateFailureInspector = new Mock().Object; return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, resourceFactory, - new List(), dataStoreUpdateFailureInspector, NullLoggerFactory.Instance); + new List(), NullLoggerFactory.Instance); } private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) From 25e109ce2642863eeb335f50813f190df48bcca6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 5 Nov 2020 18:40:56 +0100 Subject: [PATCH 08/24] Simplified code --- .../Queries/IQueryLayerComposer.cs | 2 +- .../Queries/Internal/QueryLayerComposer.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 51 ++++++------------- .../Repositories/IResourceWriteRepository.cs | 5 +- .../Services/JsonApiResourceService.cs | 9 ++-- 5 files changed, 23 insertions(+), 46 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index fbf5e59fd2..69d9bfcdcf 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -29,7 +29,7 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc /// /// Collects constraints and builds the secondary layer for a relationship endpoint. /// - QueryLayer ComposeLayerForRelationship(ResourceContext secondaryResourceContext); + QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext); /// /// Builds a query that filters on the specified IDs and selects them. diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 589c407f35..ebc67afc11 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -234,7 +234,7 @@ private FilterExpression CreateFilterByIds(ICollection ids, ResourceCo new LogicalExpression(LogicalOperator.And, new[] {filter, existingFilter}); } - public QueryLayer ComposeLayerForRelationship(ResourceContext secondaryResourceContext) + public QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext) { var secondaryLayer = ComposeFromConstraints(secondaryResourceContext); secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceContext); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index cc9ba06c3f..0e8b504a61 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -167,7 +167,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet - public virtual async Task TryGetPrimaryResourceForUpdateAsync(QueryLayer queryLayer) + public virtual async Task GetForUpdateAsync(QueryLayer queryLayer) { var resources = await GetAsync(queryLayer); return resources.FirstOrDefault(); @@ -289,9 +289,9 @@ private async Task SaveChangesAsync() } } - private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) + private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, TResource leftResource, object rightValue) { - var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(rightValue, relationship.Property.PropertyType); if (ShouldLoadInverseRelationship(relationship, trackedValueToAssign)) { @@ -509,33 +509,19 @@ private object TryGetValueForProperty(PropertyInfo propertyInfo) return null; } - private object EnsureRelationshipValueToAssignIsTracked(object valueToAssign, Type relationshipPropertyType) + private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType) { - if (valueToAssign is IReadOnlyCollection rightResourcesInToManyRelationship) + if (rightValue == null) { - return EnsureToManyRelationshipValueToAssignIsTracked(rightResourcesInToManyRelationship, relationshipPropertyType); + return null; } - if (valueToAssign is IIdentifiable rightResourceInToOneRelationship) - { - return _dbContext.GetTrackedOrAttach(rightResourceInToOneRelationship); - } + var rightResources = TypeHelper.ExtractResources(rightValue); + var rightResourcesTracked = rightResources.Select(resource => _dbContext.GetTrackedOrAttach(resource)).ToArray(); - return null; - } - - private IEnumerable EnsureToManyRelationshipValueToAssignIsTracked(IReadOnlyCollection rightResources, Type rightCollectionType) - { - var rightResourcesTracked = new object[rightResources.Count]; - - int index = 0; - foreach (var rightResource in rightResources) - { - rightResourcesTracked[index] = _dbContext.GetTrackedOrAttach(rightResource); - index++; - } - - return TypeHelper.CopyToTypedCollection(rightResourcesTracked, rightCollectionType); + return rightValue is IEnumerable + ? (object) TypeHelper.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) + : rightResourcesTracked.Single(); } private void DetachRelationships(IIdentifiable resource) @@ -543,16 +529,9 @@ private void DetachRelationships(IIdentifiable resource) foreach (var relationship in _targetedFields.Relationships) { var rightValue = relationship.GetValue(resource); - - if (rightValue is IEnumerable rightResources) - { - DetachEntities(rightResources.ToArray()); - } - else if (rightValue != null) - { - DetachEntities(new [] { rightValue }); - _dbContext.Entry(rightValue).State = EntityState.Detached; - } + var rightResources = TypeHelper.ExtractResources(rightValue); + + DetachEntities(rightResources); } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 31e10e022f..04ca3bc305 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -50,9 +50,8 @@ public interface IResourceWriteRepository Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds); /// - /// Attempts to retrieve the primary resource during a create/update/delete request. + /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for update. /// - /// - Task TryGetPrimaryResourceForUpdateAsync(QueryLayer queryLayer); + Task GetForUpdateAsync(QueryLayer queryLayer); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 15aaa59748..9038f39007 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -118,7 +118,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN if (_request.IsCollection && _options.IncludeTotalResourceCount) { // TODO: Consider support for pagination links on secondary resource collection. This requires to call Count() on the inverse relationship (which may not exist). - // For /blogs/{id}/articles we need to execute Count(Articles.Where(article => article.Blog.Id == 1 && article.Blog.existingFilter))) to determine TotalResourceCount. + // For /blogs/1/articles we need to execute Count(Articles.Where(article => article.Blog.Id == 1 && article.Blog.existingFilter))) to determine TotalResourceCount. // This also means we need to invoke ResourceRepository
.CountAsync() from ResourceService. // And we should call BlogResourceDefinition.OnApplyFilter to filter out soft-deleted blogs and translate from equals('IsDeleted','false') to equals('Blog.IsDeleted','false') } @@ -133,8 +133,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN var secondaryResourceOrResources = _request.Relationship.GetValue(primaryResource); if (secondaryResourceOrResources is ICollection secondaryResources && - secondaryLayer.Pagination?.PageSize != null && - secondaryLayer.Pagination.PageSize.Value == secondaryResources.Count) + secondaryLayer.Pagination?.PageSize?.Value == secondaryResources.Count) { _paginationContext.IsPageFull = true; } @@ -152,7 +151,7 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); - var secondaryLayer = _queryLayerComposer.ComposeLayerForRelationship(_request.SecondaryResource); + var secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResource); var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); var primaryResources = await _repository.GetAsync(primaryLayer); @@ -386,7 +385,7 @@ private async Task TryGetPrimaryResourceForReadAsync(TId id, TopField private async Task GetPrimaryResourceForUpdateAsync(TId id) { var queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); - var resourceFromDatabase = await _repository.TryGetPrimaryResourceForUpdateAsync(queryLayer); + var resourceFromDatabase = await _repository.GetForUpdateAsync(queryLayer); AssertPrimaryResourceExists(resourceFromDatabase); return resourceFromDatabase; } From cede530c9026ac2d88d0a5499ea78e0820b4d308 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 11:53:58 +0100 Subject: [PATCH 09/24] More refactorings --- .../Queries/IQueryLayerComposer.cs | 15 +- .../Queries/Internal/QueryLayerComposer.cs | 172 ++++++++++-------- .../Queries/TopFieldSelection.cs | 23 +++ .../DataStoreUpdateFailureInspector.cs | 13 +- .../EntityFrameworkCoreRepository.cs | 6 +- .../Services/JsonApiResourceService.cs | 93 +++------- 6 files changed, 168 insertions(+), 154 deletions(-) create mode 100644 src/JsonApiDotNetCore/Queries/TopFieldSelection.cs diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index 69d9bfcdcf..6e8cd226de 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -15,16 +15,20 @@ public interface IQueryLayerComposer /// FilterExpression GetTopFilterFromConstraints(); + /// + /// Builds a filter to match on the specified IDs. + /// + FilterExpression GetFilterOnResourceIds(ICollection ids, ResourceContext resourceContext); + /// /// Collects constraints and builds a out of them, used to retrieve the actual resources. /// QueryLayer ComposeFromConstraints(ResourceContext requestResource); /// - /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. + /// Collects constraints and builds a out of them, used to retrieve one resource. /// - QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, - TId primaryId, RelationshipAttribute secondaryRelationship); + QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection); /// /// Collects constraints and builds the secondary layer for a relationship endpoint. @@ -32,9 +36,10 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext); /// - /// Builds a query that filters on the specified IDs and selects them. + /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. /// - QueryLayer ComposeForFilterOnResourceIds(ISet typedIds, ResourceContext resourceContext); + QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, + TId primaryId, RelationshipAttribute secondaryRelationship); /// /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete request. diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index ebc67afc11..5da08df663 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -37,36 +37,38 @@ public QueryLayerComposer( /// public FilterExpression GetTopFilterFromConstraints() { - var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); + var constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); var topFilters = constraints - .Where(c => c.Scope == null) - .Select(c => c.Expression) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) .OfType() .ToArray(); - if (!topFilters.Any()) + if (topFilters.Length > 1) { - return null; + return new LogicalExpression(LogicalOperator.And, topFilters); } - if (topFilters.Length == 1) - { - return topFilters[0]; - } + return topFilters.Length == 1 ? topFilters[0] : null; + } + + /// + public FilterExpression GetFilterOnResourceIds(ICollection ids, ResourceContext resourceContext) + { + if (ids == null) throw new ArgumentNullException(nameof(ids)); + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); - return new LogicalExpression(LogicalOperator.And, topFilters); + var baseFilter = GetFilter(Array.Empty(), resourceContext); + return CreateFilterByIds(ids, resourceContext, baseFilter); } /// public QueryLayer ComposeFromConstraints(ResourceContext requestResource) { - if (requestResource == null) - { - throw new ArgumentNullException(nameof(requestResource)); - } + if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); - var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); + var constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); var topLayer = ComposeTopLayer(constraints, requestResource); topLayer.Include = ComposeChildren(topLayer, constraints); @@ -77,8 +79,8 @@ public QueryLayer ComposeFromConstraints(ResourceContext requestResource) private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceContext resourceContext) { var expressionsInTopScope = constraints - .Where(c => c.Scope == null) - .Select(expressionInScope => expressionInScope.Expression) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) .ToArray(); var topPagination = GetPagination(expressionsInTopScope, resourceContext); @@ -97,11 +99,11 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R }; } - private IncludeExpression ComposeChildren(QueryLayer topLayer, ExpressionInScope[] constraints) + private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection constraints) { var include = constraints - .Where(c => c.Scope == null) - .Select(expressionInScope => expressionInScope.Expression).OfType() + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression).OfType() .FirstOrDefault() ?? IncludeExpression.Empty; var includeElements = @@ -113,7 +115,7 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ExpressionInScope } private IReadOnlyCollection ProcessIncludeSet(IReadOnlyCollection includeElements, - QueryLayer parentLayer, ICollection parentRelationshipChain, ExpressionInScope[] constraints) + QueryLayer parentLayer, ICollection parentRelationshipChain, ICollection constraints) { includeElements = GetIncludeElements(includeElements, parentLayer.ResourceContext) ?? Array.Empty(); @@ -131,8 +133,9 @@ private IReadOnlyCollection ProcessIncludeSet(IReadOnl }; var expressionsInCurrentScope = constraints - .Where(c => c.Scope != null && c.Scope.Fields.SequenceEqual(relationshipChain)) - .Select(expressionInScope => expressionInScope.Expression) + .Where(constraint => + constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) + .Select(constraint => constraint.Expression) .ToArray(); var resourceContext = @@ -165,7 +168,7 @@ private IReadOnlyCollection ProcessIncludeSet(IReadOnl return !updatesInChildren.Any() ? includeElements : ApplyIncludeElementUpdates(includeElements, updatesInChildren); } - private static IReadOnlyCollection ApplyIncludeElementUpdates(IReadOnlyCollection includeElements, + private static IReadOnlyCollection ApplyIncludeElementUpdates(IEnumerable includeElements, IDictionary> updatesInChildren) { var newIncludeElements = new List(includeElements); @@ -179,13 +182,67 @@ private static IReadOnlyCollection ApplyIncludeElement return newIncludeElements; } + /// + public QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection) + { + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); + + var queryLayer = ComposeFromConstraints(resourceContext); + queryLayer.Sort = null; + queryLayer.Pagination = null; + queryLayer.Filter = CreateFilterByIds(new[] {id}, resourceContext, queryLayer.Filter); + + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) + { + var idAttribute = GetIdAttribute(resourceContext); + queryLayer.Projection = new Dictionary {{idAttribute, null}}; + } + else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null) + { + // Discard any top-level ?fields= or attribute exclusions from resource definition, because we need the full database row. + while (queryLayer.Projection.Any(pair => pair.Key is AttrAttribute)) + { + queryLayer.Projection.Remove(queryLayer.Projection.First(pair => pair.Key is AttrAttribute)); + } + } + + return queryLayer; + } + + /// + public QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext) + { + if (secondaryResourceContext == null) throw new ArgumentNullException(nameof(secondaryResourceContext)); + + var secondaryLayer = ComposeFromConstraints(secondaryResourceContext); + secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceContext); + secondaryLayer.Include = null; + + return secondaryLayer; + } + + private IDictionary GetProjectionForRelationship(ResourceContext secondaryResourceContext) + { + var secondaryIdAttribute = GetIdAttribute(secondaryResourceContext); + var sparseFieldSet = new SparseFieldSetExpression(new[] {secondaryIdAttribute}); + + var secondaryProjection = GetSparseFieldSetProjection(new[] {sparseFieldSet}, secondaryResourceContext) ?? new Dictionary(); + secondaryProjection[secondaryIdAttribute] = null; + + return secondaryProjection; + } + /// public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, RelationshipAttribute secondaryRelationship) { + if (secondaryLayer == null) throw new ArgumentNullException(nameof(secondaryLayer)); + if (primaryResourceContext == null) throw new ArgumentNullException(nameof(primaryResourceContext)); + if (secondaryRelationship == null) throw new ArgumentNullException(nameof(secondaryRelationship)); + var innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; - var primaryIdAttribute = primaryResourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + var primaryIdAttribute = GetIdAttribute(primaryResourceContext); var sparseFieldSet = new SparseFieldSetExpression(new[] { primaryIdAttribute }); var primaryProjection = GetSparseFieldSetProjection(new[] { sparseFieldSet }, primaryResourceContext) ?? new Dictionary(); @@ -213,7 +270,7 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression r private FilterExpression CreateFilterByIds(ICollection ids, ResourceContext resourceContext, FilterExpression existingFilter) { - var primaryIdAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + var primaryIdAttribute = GetIdAttribute(resourceContext); var idChain = new ResourceFieldChainExpression(primaryIdAttribute); FilterExpression filter = null; @@ -234,60 +291,20 @@ private FilterExpression CreateFilterByIds(ICollection ids, ResourceCo new LogicalExpression(LogicalOperator.And, new[] {filter, existingFilter}); } - public QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext) - { - var secondaryLayer = ComposeFromConstraints(secondaryResourceContext); - secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceContext); - secondaryLayer.Include = null; - - return secondaryLayer; - } - - private IDictionary GetProjectionForRelationship(ResourceContext secondaryResourceContext) - { - var secondaryIdAttribute = secondaryResourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - var sparseFieldSet = new SparseFieldSetExpression(new[] {secondaryIdAttribute}); - - var secondaryProjection = GetSparseFieldSetProjection(new[] {sparseFieldSet}, secondaryResourceContext) ?? new Dictionary(); - secondaryProjection[secondaryIdAttribute] = null; - - return secondaryProjection; - } - /// - public QueryLayer ComposeForFilterOnResourceIds(ISet typedIds, ResourceContext resourceContext) - { - if (typedIds == null) throw new ArgumentNullException(nameof(typedIds)); - if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); - - var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); - - var baseFilter = GetFilter(Array.Empty(), resourceContext); - var idsFilter = CreateFilterByIds(typedIds, resourceContext, baseFilter); - - return new QueryLayer(resourceContext) - { - Filter = idsFilter, - Projection = new Dictionary - { - [idAttribute] = null - } - }; - } - public QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource) { - var primaryLayer = ComposeTopLayer(Array.Empty(), primaryResource); - - primaryLayer.Filter = CreateFilterByIds(new[] {id}, primaryResource, primaryLayer.Filter); + if (primaryResource == null) throw new ArgumentNullException(nameof(primaryResource)); var includeElements = _targetedFields.Relationships .Select(relationship => new IncludeElementExpression(relationship)).ToArray(); - if (includeElements.Any()) - { - primaryLayer.Include = new IncludeExpression(includeElements); - } + var primaryLayer = ComposeTopLayer(Array.Empty(), primaryResource); + primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : null; + primaryLayer.Sort = null; + primaryLayer.Pagination = null; + primaryLayer.Filter = CreateFilterByIds(new[] {id}, primaryResource, primaryLayer.Filter); + primaryLayer.Projection = null; return primaryLayer; } @@ -323,7 +340,7 @@ protected virtual SortExpression GetSort(IReadOnlyCollection ex if (sort == null) { - var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + var idAttribute = GetIdAttribute(resourceContext); sort = new SortExpression(new[] {new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true)}); } @@ -361,10 +378,15 @@ protected virtual IDictionary GetSparseField return null; } - var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + var idAttribute = GetIdAttribute(resourceContext); attributes.Add(idAttribute); return attributes.Cast().ToDictionary(key => key, value => (QueryLayer) null); } + + private static AttrAttribute GetIdAttribute(ResourceContext resourceContext) + { + return resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + } } } diff --git a/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs b/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs new file mode 100644 index 0000000000..d5203ff537 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs @@ -0,0 +1,23 @@ +namespace JsonApiDotNetCore.Queries +{ + /// + /// Indicates how to override sparse fieldset selection coming from constraints. + /// + public enum TopFieldSelection + { + /// + /// Preserves the existing selection of attributes and/or relationships. + /// + PreserveExisting, + + /// + /// Preserves included relationships, but selects all resource attributes. + /// + WithAllAttributes, + + /// + /// Discards any included relationships and selects only resource ID. + /// + OnlyIdAttribute + } +} diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs index 789a1b3177..6307c57ffa 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs @@ -88,8 +88,19 @@ private async Task> GetExistingResourceIds(ICollection(); } + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var typedIds = resourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); - var queryLayer = _queryLayerComposer.ComposeForFilterOnResourceIds(typedIds, resourceContext); + var filter = _queryLayerComposer.GetFilterOnResourceIds(typedIds, resourceContext); + + var queryLayer = new QueryLayer(resourceContext) + { + Filter = filter, + Projection = new Dictionary + { + [idAttribute] = null + } + }; var resources = await _resourceRepositoryAccessor.GetAsync(resourceContext.ResourceType, queryLayer); return resources.Select(resource => resource.StringId).ToArray(); diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 0e8b504a61..87319a7f64 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -171,7 +171,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet()) { @@ -321,7 +321,7 @@ private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) return false; } - private TResource CreatePrimaryResourceWithAssignedId(TId id) + private TResource CreateResourceWithAssignedId(TId id) { var resource = _resourceFactory.CreateInstance(); resource.Id = id; diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 9038f39007..36aec571ed 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -9,7 +9,6 @@ using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -96,7 +95,8 @@ public virtual async Task GetAsync(TId id) _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetSingle); - var primaryResource = await GetPrimaryResourceForReadAsync(id, TopFieldSelection.PreserveExisting); + var primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting); + AssertPrimaryResourceExists(primaryResource); _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle); _hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle); @@ -188,7 +188,7 @@ public virtual async Task CreateAsync(TResource resource) } catch (DataStoreUpdateException) { - var existingResource = await TryGetPrimaryResourceForReadAsync(resource.Id, TopFieldSelection.OnlyIdAttribute); + var existingResource = await TryGetPrimaryResourceByIdAsync(resource.Id, TopFieldSelection.OnlyIdAttribute); if (existingResource != null) { throw new ResourceAlreadyExistsException(resource.StringId, _request.PrimaryResource.PublicName); @@ -198,7 +198,8 @@ public virtual async Task CreateAsync(TResource resource) throw; } - var resourceFromDatabase = await GetPrimaryResourceForReadAsync(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); + var resourceFromDatabase = await TryGetPrimaryResourceByIdAsync(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); + AssertPrimaryResourceExists(resourceFromDatabase); _hookExecutor.AfterCreate(resourceFromDatabase); @@ -232,7 +233,9 @@ public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshi } catch (DataStoreUpdateException) { - await GetPrimaryResourceForReadAsync(primaryId, TopFieldSelection.OnlyIdAttribute); + var primaryResource = await TryGetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute); + AssertPrimaryResourceExists(primaryResource); + await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); throw; @@ -265,7 +268,8 @@ public virtual async Task UpdateAsync(TId id, TResource resource) throw; } - TResource afterResourceFromDatabase = await GetPrimaryResourceForReadAsync(id, TopFieldSelection.WithAllAttributes); + TResource afterResourceFromDatabase = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes); + AssertPrimaryResourceExists(afterResourceFromDatabase); _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); @@ -312,8 +316,9 @@ public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - await _hookExecutor.BeforeDeleteAsync(id, - async () => await GetPrimaryResourceForReadAsync(id, TopFieldSelection.WithAllAttributes)); + TResource resourceForHooksCached = null; + await _hookExecutor.BeforeDeleteAsync(id, async () => + resourceForHooksCached = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); try { @@ -321,12 +326,13 @@ await _hookExecutor.BeforeDeleteAsync(id, } catch (DataStoreUpdateException) { - await GetPrimaryResourceForReadAsync(id, TopFieldSelection.OnlyIdAttribute); + var primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + AssertPrimaryResourceExists(primaryResource); + throw; } - await _hookExecutor.AfterDeleteAsync(id, - async () => await GetPrimaryResourceForReadAsync(id, TopFieldSelection.WithAllAttributes)); + await _hookExecutor.AfterDeleteAsync(id, () => Task.FromResult(resourceForHooksCached)); } /// @@ -348,35 +354,9 @@ public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relati } } - private async Task GetPrimaryResourceForReadAsync(TId id, TopFieldSelection fieldSelection) + private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection) { - var primaryResource = await TryGetPrimaryResourceForReadAsync(id, fieldSelection); - - AssertPrimaryResourceExists(primaryResource); - - return primaryResource; - } - - private async Task TryGetPrimaryResourceForReadAsync(TId id, TopFieldSelection fieldSelection) - { - var primaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResource); - primaryLayer.Sort = null; - primaryLayer.Pagination = null; - primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); - - if (fieldSelection == TopFieldSelection.OnlyIdAttribute) - { - var idAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - primaryLayer.Projection = new Dictionary {{idAttribute, null}}; - } - else if (fieldSelection == TopFieldSelection.WithAllAttributes && primaryLayer.Projection != null) - { - // Discard any top-level ?fields= or attribute exclusions from resource definition, because we need the full database row. - while (primaryLayer.Projection.Any(p => p.Key is AttrAttribute)) - { - primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); - } - } + var primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResource, fieldSelection); var primaryResources = await _repository.GetAsync(primaryLayer); return primaryResources.SingleOrDefault(); @@ -385,21 +365,10 @@ private async Task TryGetPrimaryResourceForReadAsync(TId id, TopField private async Task GetPrimaryResourceForUpdateAsync(TId id) { var queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); - var resourceFromDatabase = await _repository.GetForUpdateAsync(queryLayer); - AssertPrimaryResourceExists(resourceFromDatabase); - return resourceFromDatabase; - } - - private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) - { - var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - - FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); - - return existingFilter == null - ? filterById - : new LogicalExpression(LogicalOperator.And, new[] { filterById, existingFilter }); + var resource = await _repository.GetForUpdateAsync(queryLayer); + + AssertPrimaryResourceExists(resource); + return resource; } private void AssertPrimaryResourceExists(TResource resource) @@ -426,22 +395,6 @@ private void AssertRelationshipIsToMany() throw new ToManyRelationshipRequiredException(relationship.PublicName); } } - - private enum TopFieldSelection - { - /// - /// Preserves included relationships, but selects all resource attributes. - /// - WithAllAttributes, - /// - /// Discards any included relationships and selects only resource ID. - /// - OnlyIdAttribute, - /// - /// Preserves the existing selection of attributes and/or relationships. - /// - PreserveExisting - } } /// From ba47b8ee5bccafc7bfdce8167e5f473575fb4014 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 13:18:20 +0100 Subject: [PATCH 10/24] Added services.AddResourceRepository() --- docs/usage/extensibility/repositories.md | 17 ++++- docs/usage/extensibility/services.md | 4 +- .../ServiceCollectionExtensions.cs | 50 ++++++++++----- .../CompositeKeys/CompositeKeyTests.cs | 5 +- .../IServiceCollectionExtensionsTests.cs | 64 ++++++++++++++++++- 5 files changed, 116 insertions(+), 24 deletions(-) diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md index eb59c81d7c..0f953d06a4 100644 --- a/docs/usage/extensibility/repositories.md +++ b/docs/usage/extensibility/repositories.md @@ -3,7 +3,7 @@ If you want to use a data access technology other than Entity Framework Core, you can create an implementation of `IResourceRepository`. If you only need minor changes you can override the methods defined in `EntityFrameworkCoreRepository`. -The repository should then be added to the service collection in Startup.cs. +The repository should then be registered in Startup.cs. ```c# public void ConfigureServices(IServiceCollection services) @@ -12,6 +12,21 @@ public void ConfigureServices(IServiceCollection services) } ``` +In v4.0 we introduced an extension method that you can use to register a resource repository on all of its JsonApiDotNetCore interfaces. +This is helpful when you implement a subset of the resource interfaces and want to register them all in one go. + +Note: If you're using service discovery, this happens automatically. + +```c# +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddResourceRepository(); + } +} +``` + A sample implementation that performs authorization might look like this. All of the methods in EntityFrameworkCoreRepository will use the `GetAll()` method to get the `DbSet`, so this is a good method to apply filters such as user or tenant authorization. diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index c99911df42..2c3b612e74 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -115,7 +115,7 @@ IResourceService PATCH /{id}/relationships/{relationship} ``` -In order to take advantage of these interfaces you first need to inject the service for each implemented interface. +In order to take advantage of these interfaces you first need to register the service for each implemented interface. ```c# public class ArticleService : ICreateService
, IDeleteService
@@ -136,6 +136,8 @@ public class Startup In v3.0 we introduced an extension method that you can use to register a resource service on all of its JsonApiDotNetCore interfaces. This is helpful when you implement a subset of the resource interfaces and want to register them all in one go. +Note: If you're using service discovery, this happens automatically. + ```c# public class Startup { diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 452fcd01cc..841ccc8374 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Client.Internal; using JsonApiDotNetCore.Services; @@ -84,17 +85,36 @@ public static IServiceCollection AddResourceService(this IServiceColle { if (services == null) throw new ArgumentNullException(nameof(services)); - var typeImplementsAnExpectedInterface = false; - var serviceImplementationType = typeof(TService); - var resourceDescriptor = TryGetResourceTypeFromServiceImplementation(serviceImplementationType); + RegisterForConstructedType(services, typeof(TService), ServiceDiscoveryFacade.ServiceInterfaces); + + return services; + } + + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource repository interfaces, + /// such as and . + /// + public static IServiceCollection AddResourceRepository(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + RegisterForConstructedType(services, typeof(TRepository), ServiceDiscoveryFacade.RepositoryInterfaces); + + return services; + } + + private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable openGenericInterfaces) + { + bool seenCompatibleInterface = false; + var resourceDescriptor = TryGetResourceTypeFromServiceImplementation(implementationType); if (resourceDescriptor != null) { - foreach (var openGenericType in ServiceDiscoveryFacade.ServiceInterfaces) + foreach (var openGenericInterface in openGenericInterfaces) { // A shorthand interface is one where the ID type is omitted. // e.g. IResourceService is the shorthand for IResourceService - var isShorthandInterface = openGenericType.GetTypeInfo().GenericTypeParameters.Length == 1; + var isShorthandInterface = openGenericInterface.GetTypeInfo().GenericTypeParameters.Length == 1; if (isShorthandInterface && resourceDescriptor.IdType != typeof(int)) { // We can't create a shorthand for ID types other than int. @@ -102,25 +122,23 @@ public static IServiceCollection AddResourceService(this IServiceColle } var constructedType = isShorthandInterface - ? openGenericType.MakeGenericType(resourceDescriptor.ResourceType) - : openGenericType.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); + ? openGenericInterface.MakeGenericType(resourceDescriptor.ResourceType) + : openGenericInterface.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); - if (constructedType.IsAssignableFrom(serviceImplementationType)) + if (constructedType.IsAssignableFrom(implementationType)) { - services.AddScoped(constructedType, serviceImplementationType); - typeImplementsAnExpectedInterface = true; + services.AddScoped(constructedType, implementationType); + seenCompatibleInterface = true; } } } - if (!typeImplementsAnExpectedInterface) - throw new InvalidConfigurationException($"{serviceImplementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); - - return services; + if (!seenCompatibleInterface) + { + throw new InvalidConfigurationException( + $"{implementationType} does not implement any of the expected JsonApiDotNetCore interfaces.");} } - // TODO: Should add AddResourceRepository, which registers the read/write/shared interfaces (similar to AddResourceService) + update docs. - private static ResourceDescriptor TryGetResourceTypeFromServiceImplementation(Type serviceType) { foreach (var @interface in serviceType.GetInterfaces()) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 6e5504e592..f4cfd47541 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -23,9 +22,7 @@ public CompositeKeyTests(IntegrationTestContext { - // TODO: Replace with single call (see TODO in ServiceCollectionExtensions). - services.AddScoped, CarRepository>(); - services.AddScoped, CarRepository>(); + services.AddResourceRepository(); }); var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 6051f8db33..2e68031414 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -4,6 +4,8 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; @@ -134,6 +136,38 @@ public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces( Assert.Throws(() => services.AddResourceService()); } + [Fact] + public void AddResourceRepository_Registers_All_Shorthand_Repository_Interfaces() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddResourceRepository(); + + // Assert + var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService(typeof(IResourceRepository))); + Assert.IsType(provider.GetRequiredService(typeof(IResourceReadRepository))); + Assert.IsType(provider.GetRequiredService(typeof(IResourceWriteRepository))); + } + + [Fact] + public void AddResourceRepository_Registers_All_LongForm_Repository_Interfaces() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddResourceRepository(); + + // Assert + var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService(typeof(IResourceRepository))); + Assert.IsType(provider.GetRequiredService(typeof(IResourceReadRepository))); + Assert.IsType(provider.GetRequiredService(typeof(IResourceWriteRepository))); + } + [Fact] public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified() { @@ -157,7 +191,7 @@ public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified( public sealed class IntResource : Identifiable { } public class GuidResource : Identifiable { } - private class IntResourceService : IResourceService + private sealed class IntResourceService : IResourceService { public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(int id) => throw new NotImplementedException(); @@ -171,7 +205,7 @@ private class IntResourceService : IResourceService public Task RemoveFromToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } - private class GuidResourceService : IResourceService + private sealed class GuidResourceService : IResourceService { public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(Guid id) => throw new NotImplementedException(); @@ -185,6 +219,32 @@ private class GuidResourceService : IResourceService public Task RemoveFromToManyRelationshipAsync(Guid primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } + private sealed class IntResourceRepository : IResourceRepository + { + public Task> GetAsync(QueryLayer layer) => throw new NotImplementedException(); + public Task CountAsync(FilterExpression topFilter) => throw new NotImplementedException(); + public Task CreateAsync(IntResource resource) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int primaryId, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task UpdateAsync(IntResource resourceFromRequest, IntResource resourceFromDatabase) => throw new NotImplementedException(); + public Task SetRelationshipAsync(IntResource primaryResource, object secondaryResourceIds) => throw new NotImplementedException(); + public Task DeleteAsync(int id) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(IntResource primaryResource, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task GetForUpdateAsync(QueryLayer queryLayer) => throw new NotImplementedException(); + } + + private sealed class GuidResourceRepository : IResourceRepository + { + public Task> GetAsync(QueryLayer layer) => throw new NotImplementedException(); + public Task CountAsync(FilterExpression topFilter) => throw new NotImplementedException(); + public Task CreateAsync(GuidResource resource) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid primaryId, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task UpdateAsync(GuidResource resourceFromRequest, GuidResource resourceFromDatabase) => throw new NotImplementedException(); + public Task SetRelationshipAsync(GuidResource primaryResource, object secondaryResourceIds) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(GuidResource primaryResource, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task GetForUpdateAsync(QueryLayer queryLayer) => throw new NotImplementedException(); + } + public class TestContext : DbContext { public TestContext(DbContextOptions options) : base(options) From 9ff1c887995c02827b1de573d7eee103d2cdc2d6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 13:18:53 +0100 Subject: [PATCH 11/24] removed TODO --- .../IntegrationTests/SoftDeletion/SoftDeletionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index aa8c49e2b0..7aaf444c8c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -394,7 +394,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Source.Parameter.Should().BeNull(); } - [Fact(Skip = "TODO: Make this test work again, now that we fetch the primary resource.")] + [Fact] public async Task Cannot_update_relationship_for_deleted_parent() { // Arrange From 26b7ea458583cbd4c5b58aad94b97b945bfd9a3d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 14:23:33 +0100 Subject: [PATCH 12/24] Reorder methods in calling order, renames, expose FlushFromCache/SaveChangesAsync --- .../EntityFrameworkCoreRepository.cs | 417 +++++++++--------- 1 file changed, 206 insertions(+), 211 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 87319a7f64..e7ff4da034 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -19,7 +19,7 @@ using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; -// TODO: Tests that cover relationship updates with required relationships. All relationships right are currently optional. +// TODO: Tests that cover relationship updates with required relationships. All relationships right now are currently optional. // - Setting a required relationship to null // - Creating resource with resource // - One-to-one required / optional => what is the current behavior? @@ -27,23 +27,6 @@ // - How and where to read EF Core metadata when "required-relationship-error" is triggered? namespace JsonApiDotNetCore.Repositories { - /// - /// Implements the foundational repository implementation that uses Entity Framework Core. - /// - public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository - where TResource : class, IIdentifiable - { - public EntityFrameworkCoreRepository( - ITargetedFields targetedFields, - IDbContextResolver contextResolver, - IResourceGraph resourceGraph, - IResourceFactory resourceFactory, - IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { } - } - /// /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. /// @@ -57,7 +40,8 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IEnumerable _constraintProviders; private readonly TraceLogWriter> _traceWriter; - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, + public EntityFrameworkCoreRepository( + ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, @@ -83,7 +67,6 @@ public virtual async Task> GetAsync(QueryLayer la if (layer == null) throw new ArgumentNullException(nameof(layer)); IQueryable query = ApplyQueryLayer(layer); - return await query.ToListAsync(); } @@ -133,7 +116,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) var expression = builder.ApplyQuery(layer); return source.Provider.CreateQuery(expression); } - + protected virtual IQueryable GetAll() { return _dbContext.Set(); @@ -148,7 +131,7 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationship in _targetedFields.Relationships) { var rightValue = relationship.GetValue(resource); - await ApplyRelationshipUpdate(relationship, resource, rightValue); + await UpdateRelationship(relationship, resource, rightValue); } _dbContext.Set().Add(resource); @@ -157,6 +140,35 @@ public virtual async Task CreateAsync(TResource resource) FlushFromCache(resource); } + protected void FlushFromCache(IIdentifiable resource) + { + resource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); + if (resource != null) + { + DetachEntities(new [] { resource }); + DetachRelationships(resource); + } + } + + private void DetachEntities(IEnumerable entities) + { + foreach (var entity in entities) + { + _dbContext.Entry(entity).State = EntityState.Detached; + } + } + + private void DetachRelationships(IIdentifiable resource) + { + foreach (var relationship in _targetedFields.Relationships) + { + var rightValue = relationship.GetValue(resource); + var rightResources = TypeHelper.ExtractResources(rightValue); + + DetachEntities(rightResources); + } + } + /// public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds) { @@ -175,11 +187,89 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds) + { + // TODO: Finalize this. + var throughEntitiesFilter = new ThroughEntitiesFilter(_dbContext, relationship); + var typedRightIds = secondaryResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); + var throughEntities = await throughEntitiesFilter.GetBy(primaryResourceId, typedRightIds); + + // Alternative approaches: + // throughEntities = await GetFilteredThroughEntities_DynamicQueryBuilding(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); + // throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); + + var rightResources = throughEntities.Select(ConstructRightResourceOfHasManyRelationship).ToHashSet(); + secondaryResourceIds.ExceptWith(rightResources.ToHashSet()); + + DetachEntities(throughEntities); + } + + private IIdentifiable ConstructRightResourceOfHasManyRelationship(object entity) + { + var relationship = (HasManyThroughAttribute)_targetedFields.Relationships.Single(); + + var rightResource = _resourceFactory.CreateInstance(relationship.RightType); + rightResource.StringId = relationship.RightIdProperty.GetValue(entity).ToString(); + + return rightResource; + } + + private async Task GetFilteredThroughEntities_DynamicQueryBuilding(TId leftId, ISet rightIds, HasManyThroughAttribute relationship) + { + var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); + + var filter = ThroughEntitiesFilter.GetEqualsAndContainsFilter(leftId, rightIds, relationship, throughEntityParameter); + + var predicate = Expression.Lambda(filter, throughEntityParameter); + + IQueryable throughSource = _dbContext.Set(relationship.ThroughType); + var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, predicate); + + dynamic query = throughSource.Provider.CreateQuery(whereClause); + IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); + + return result.Cast().ToArray(); + } + + private async Task GetFilteredThroughEntities_QueryBuilderCall(TId leftId, ISet rightIds, HasManyThroughAttribute relationship) + { + var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); + var comparisionId = new LiteralConstantExpression(leftId.ToString()); + FilterExpression equalsFilter = new ComparisonExpression(ComparisonOperator.Equals, comparisonTargetField, comparisionId); + + var equalsAnyOfTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty }); + var equalsAnyOfIds = rightIds.Select(r => new LiteralConstantExpression(r.ToString())).ToArray(); + FilterExpression containsFilter = new EqualsAnyOfExpression(equalsAnyOfTargetField, equalsAnyOfIds); + + var filter = new LogicalExpression(LogicalOperator.And, new QueryExpression[] { equalsFilter, containsFilter } ); + + IQueryable throughSource = _dbContext.Set(relationship.ThroughType); + + var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); + var scope = scopeFactory.CreateScope(relationship.ThroughType); + + var whereClauseBuilder = new WhereClauseBuilder(throughSource.Expression, scope, typeof(Queryable)); + var whereClause = whereClauseBuilder.ApplyWhere(filter); + + dynamic query = throughSource.Provider.CreateQuery(whereClause); + IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); + + return result.Cast().ToArray(); + } + + private TResource CreateResourceWithAssignedId(TId id) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + return resource; + } + /// public virtual async Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds) { @@ -187,7 +277,7 @@ public virtual async Task SetRelationshipAsync(TResource primaryResource, object var relationship = _targetedFields.Relationships.Single(); - await ApplyRelationshipUpdate(relationship, primaryResource, secondaryResourceIds); + await UpdateRelationship(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); } @@ -202,7 +292,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r foreach (var relationship in _targetedFields.Relationships) { var rightResources = relationship.GetValue(resourceFromRequest); - await ApplyRelationshipUpdate(relationship, resourceFromDatabase, rightResources); + await UpdateRelationship(relationship, resourceFromDatabase, rightResources); } foreach (var attribute in _targetedFields.Attributes) @@ -224,7 +314,9 @@ public virtual async Task DeleteAsync(TId id) foreach (var relationship in _resourceGraph.GetRelationships()) { - if (ShouldLoadRelationshipForSafeDeletion(relationship)) + // Loads the data of the relationship if in EF Core it is configured in such a way that loading the related + // entities into memory is required for successfully executing the selected deletion behavior. + if (RequiresLoadOfRelationshipForDeletion(relationship)) { var navigation = GetNavigationEntry(resource, relationship); await navigation.LoadAsync(); @@ -236,21 +328,38 @@ public virtual async Task DeleteAsync(TId id) await SaveChangesAsync(); } - /// - /// Loads the data of the relationship if in EF Core it is configured in such a way that loading the related - /// entities into memory is required for successfully executing the selected deletion behavior. - /// - private bool ShouldLoadRelationshipForSafeDeletion(RelationshipAttribute relationship) + private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) { - var navigationMeta = GetNavigationMetadata(relationship); - var clientIsResponsibleForClearingForeignKeys = navigationMeta?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; + EntityEntry entityEntry = _dbContext.Entry(resource); - var isPrincipalSide = !HasForeignKeyAtLeftSide(relationship); + switch (relationship) + { + case HasOneAttribute hasOneRelationship: + { + return entityEntry.Reference(hasOneRelationship.Property.Name); + } + case HasManyAttribute hasManyRelationship: + { + return entityEntry.Collection(hasManyRelationship.Property.Name); + } + default: + { + throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'."); + } + } + } + + private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) + { + var navigation = TryGetNavigationForRelationship(relationship); + bool isClearForeignKeyRequired = navigation?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; + + bool isHasOneWithForeignKeyAtLeftSide = IsHasOneWithForeignKeyAtLeftSide(relationship); - return isPrincipalSide && clientIsResponsibleForClearingForeignKeys; + return !isHasOneWithForeignKeyAtLeftSide && isClearForeignKeyRequired; } - private INavigation GetNavigationMetadata(RelationshipAttribute relationship) + private INavigation TryGetNavigationForRelationship(RelationshipAttribute relationship) { return _dbContext.Model.FindEntityType(typeof(TResource)).FindNavigation(relationship.Property.Name); } @@ -263,10 +372,11 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryRes var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); - var rightResources = ((IEnumerable)relationship.GetValue(primaryResource)).ToHashSet(IdentifiableComparer.Instance); + var rightValue = relationship.GetValue(primaryResource); + var rightResources = ((IEnumerable)rightValue).ToHashSet(IdentifiableComparer.Instance); rightResources.ExceptWith(secondaryResourceIds); - await ApplyRelationshipUpdate(relationship, primaryResource, rightResources); + await UpdateRelationship(relationship, primaryResource, rightResources); await SaveChangesAsync(); } @@ -277,7 +387,7 @@ public virtual async Task GetForUpdateAsync(QueryLayer queryLayer) return resources.FirstOrDefault(); } - private async Task SaveChangesAsync() + protected virtual async Task SaveChangesAsync() { try { @@ -289,11 +399,11 @@ private async Task SaveChangesAsync() } } - private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, TResource leftResource, object rightValue) + private async Task UpdateRelationship(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { - var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(rightValue, relationship.Property.PropertyType); + var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); - if (ShouldLoadInverseRelationship(relationship, trackedValueToAssign)) + if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) { var entityEntry = _dbContext.Entry(trackedValueToAssign); var inversePropertyName = relationship.InverseNavigationProperty.Name; @@ -301,7 +411,7 @@ private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, T await entityEntry.Reference(inversePropertyName).LoadAsync(); } - if (HasForeignKeyAtLeftSide(relationship) && trackedValueToAssign == null) + if (IsHasOneWithForeignKeyAtLeftSide(relationship) && trackedValueToAssign == null) { PrepareChangeTrackerForNullAssignment(relationship, leftResource); } @@ -309,158 +419,63 @@ private async Task ApplyRelationshipUpdate(RelationshipAttribute relationship, T relationship.SetValue(leftResource, trackedValueToAssign); } - private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) - { - if (relationship is HasOneAttribute) - { - var navigation = GetNavigationMetadata(relationship); - - return navigation.IsDependentToPrincipal(); - } - - return false; - } - - private TResource CreateResourceWithAssignedId(TId id) - { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; - - return resource; - } - - private void FlushFromCache(IIdentifiable resource) + private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType) { - resource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); - if (resource != null) + if (rightValue == null) { - DetachEntities(new [] { resource }); - DetachRelationships(resource); + return null; } - } - - private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute relationship, TId primaryResourceId, ISet secondaryResourceIds) - { - // TODO: Finalize this. - var throughEntitiesFilter = new ThroughEntitiesFilter(_dbContext, relationship); - var typedRightIds = secondaryResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); - var throughEntities = await throughEntitiesFilter.GetBy(primaryResourceId, typedRightIds); - - // Alternative approaches: - // throughEntities = await GetFilteredThroughEntities_DynamicQueryBuilding(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - // throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - - var rightResources = throughEntities.Select(ConstructRightResourceOfHasManyRelationship).ToHashSet(); - secondaryResourceIds.ExceptWith(rightResources.ToHashSet()); - - DetachEntities(throughEntities); - } - private IIdentifiable ConstructRightResourceOfHasManyRelationship(object entity) - { - var relationship = (HasManyThroughAttribute)_targetedFields.Relationships.Single(); - - var rightResource = _resourceFactory.CreateInstance(relationship.RightType); - rightResource.StringId = relationship.RightIdProperty.GetValue(entity).ToString(); + var rightResources = TypeHelper.ExtractResources(rightValue); + var rightResourcesTracked = rightResources.Select(resource => _dbContext.GetTrackedOrAttach(resource)).ToArray(); - return rightResource; + return rightValue is IEnumerable + ? (object) TypeHelper.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) + : rightResourcesTracked.Single(); } - private async Task GetFilteredThroughEntities_DynamicQueryBuilding(TId leftId, ISet rightIds, HasManyThroughAttribute relationship) - { - var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); - - var filter = ThroughEntitiesFilter.GetEqualsAndContainsFilter(leftId, rightIds, relationship, throughEntityParameter); - - var predicate = Expression.Lambda(filter, throughEntityParameter); - - IQueryable throughSource = _dbContext.Set(relationship.ThroughType); - var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, predicate); - - dynamic query = throughSource.Provider.CreateQuery(whereClause); - IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); - - return result.Cast().ToArray(); - } - - private async Task GetFilteredThroughEntities_QueryBuilderCall(TId leftId, ISet rightIds, HasManyThroughAttribute relationship) + private static bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, object trackedValueToAssign) { - var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); - var comparisionId = new LiteralConstantExpression(leftId.ToString()); - FilterExpression equalsFilter = new ComparisonExpression(ComparisonOperator.Equals, comparisonTargetField, comparisionId); - - var equalsAnyOfTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty }); - var equalsAnyOfIds = rightIds.Select(r => new LiteralConstantExpression(r.ToString())).ToArray(); - FilterExpression containsFilter = new EqualsAnyOfExpression(equalsAnyOfTargetField, equalsAnyOfIds); - - var filter = new LogicalExpression(LogicalOperator.And, new QueryExpression[] { equalsFilter, containsFilter } ); - - IQueryable throughSource = _dbContext.Set(relationship.ThroughType); - - var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); - var scope = scopeFactory.CreateScope(relationship.ThroughType); - - var whereClauseBuilder = new WhereClauseBuilder(throughSource.Expression, scope, typeof(Queryable)); - var whereClause = whereClauseBuilder.ApplyWhere(filter); - - dynamic query = throughSource.Provider.CreateQuery(whereClause); - IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); - - return result.Cast().ToArray(); + // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + return trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship); } - private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) + private static bool IsOneToOneRelationship(RelationshipAttribute relationship) { - EntityEntry entityEntry = _dbContext.Entry(resource); - - switch (relationship) + if (relationship is HasOneAttribute hasOneRelationship) { - case HasManyAttribute hasManyRelationship: - { - return entityEntry.Collection(hasManyRelationship.Property.Name); - } - case HasOneAttribute hasOneRelationship: - { - return entityEntry.Reference(hasOneRelationship.Property.Name); - } + var elementType = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); + return elementType == null; } - return null; - } - - /// - /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - /// - private bool ShouldLoadInverseRelationship(RelationshipAttribute relationship, object trackedValueToAssign) - { - return trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship); + return false; } - private bool IsOneToOneRelationship(RelationshipAttribute relationship) + private bool IsHasOneWithForeignKeyAtLeftSide(RelationshipAttribute relationship) { - if (relationship is HasOneAttribute hasOneRelationship) + if (relationship is HasOneAttribute) { - var elementType = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); - return elementType == null; + var navigation = TryGetNavigationForRelationship(relationship); + return navigation != null && navigation.IsDependentToPrincipal(); } return false; } - /// - /// If a (shadow) foreign key is already loaded on the left resource of a relationship, it is not possible to - /// set it to null by just assigning null to the navigation property and marking it as modified. - /// Instead, when marking it as modified, it will mark the pre-existing foreign key value as modified too but without setting its value to null. - /// One way to work around this is by loading the relationship before setting it to null. Another approach (as done in this method) is - /// tricking the change tracker into recognizing the null assignment by first assigning a placeholder entity to the navigation property, and then - /// setting it to null. - /// private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relationship, TResource leftResource) { + // If a (shadow) foreign key is already loaded on the left resource of a relationship, it is not possible to + // set it to null by just assigning null to the navigation property and marking it as modified. + // Instead, when marking it as modified, it will mark the pre-existing foreign key value as modified too, + // but without setting its value to null. + // One way to work around this is by loading the relationship before setting it to null. Another approach + // (as done here) is tricking the change tracker into recognizing the null assignment by first + // assigning a placeholder entity to the navigation property, and then setting it to null. + var placeholderRightResource = _resourceFactory.CreateInstance(relationship.RightType); // When assigning a related entity to a navigation property, it will be attached to the change tracker. - // This fails when that entity has null reference(s) for its primary key(s). + // This fails when that entity has null reference(s) for its primary key. EnsurePrimaryKeyPropertiesAreNotNull(placeholderRightResource); relationship.SetValue(leftResource, placeholderRightResource); @@ -476,16 +491,13 @@ private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) { foreach (var property in primaryKey.Properties) { - var propertyValue = TryGetValueForProperty(property.PropertyInfo); - if (propertyValue != null) - { - property.PropertyInfo.SetValue(entity, propertyValue); - } + var propertyValue = GetNonNullValueForProperty(property.PropertyInfo); + property.PropertyInfo.SetValue(entity, propertyValue); } } } - private object TryGetValueForProperty(PropertyInfo propertyInfo) + private static object GetNonNullValueForProperty(PropertyInfo propertyInfo) { var propertyType = propertyInfo.PropertyType; @@ -496,51 +508,34 @@ private object TryGetValueForProperty(PropertyInfo propertyInfo) if (Nullable.GetUnderlyingType(propertyType) != null) { - var underlyingType = propertyInfo.PropertyType.GetGenericArguments()[0]; // TODO: Write test with primary key property type int? or equivalent. - return Activator.CreateInstance(underlyingType); + propertyType = propertyInfo.PropertyType.GetGenericArguments()[0]; } - if (!propertyType.IsValueType) + if (propertyType.IsValueType) { - throw new InvalidOperationException($"Unexpected reference type '{propertyType.Name}' for primary key property '{propertyInfo.Name}'."); + return Activator.CreateInstance(propertyType); } - return null; - } - - private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType) - { - if (rightValue == null) - { - return null; - } - - var rightResources = TypeHelper.ExtractResources(rightValue); - var rightResourcesTracked = rightResources.Select(resource => _dbContext.GetTrackedOrAttach(resource)).ToArray(); - - return rightValue is IEnumerable - ? (object) TypeHelper.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) - : rightResourcesTracked.Single(); + throw new InvalidOperationException( + $"Unexpected reference type '{propertyType.Name}' for primary key property '{propertyInfo.DeclaringType?.Name}.{propertyInfo.Name}'."); } + } - private void DetachRelationships(IIdentifiable resource) - { - foreach (var relationship in _targetedFields.Relationships) - { - var rightValue = relationship.GetValue(resource); - var rightResources = TypeHelper.ExtractResources(rightValue); - - DetachEntities(rightResources); - } - } - - private void DetachEntities(IEnumerable entities) - { - foreach (var entity in entities) - { - _dbContext.Entry(entity).State = EntityState.Detached; - } - } + /// + /// Implements the foundational repository implementation that uses Entity Framework Core. + /// + public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository + where TResource : class, IIdentifiable + { + public EntityFrameworkCoreRepository( + ITargetedFields targetedFields, + IDbContextResolver contextResolver, + IResourceGraph resourceGraph, + IResourceFactory resourceFactory, + IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { } } } From 6ac48be2a5540b2bd26cfe1078559c6ce85fdbae Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 16:06:16 +0100 Subject: [PATCH 13/24] Removed ThroughEntitiesFilter --- .../JsonApiDotNetCore.csproj | 4 + .../Queries/IQueryLayerComposer.cs | 5 + .../Queries/Internal/QueryLayerComposer.cs | 42 +++++-- .../Repositories/DataStoreUpdateException.cs | 7 +- .../DataStoreUpdateFailureInspector.cs | 2 +- .../EntityFrameworkCoreRepository.cs | 113 +++++++----------- .../Repositories/IResourceWriteRepository.cs | 3 +- .../Internal/ThroughEntitiesFilter.cs | 82 ------------- .../Services/JsonApiResourceService.cs | 8 +- .../IServiceCollectionExtensionsTests.cs | 4 +- 10 files changed, 99 insertions(+), 171 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 24253becd3..9ae87879fa 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -28,4 +28,8 @@ + + + + diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index 6e8cd226de..15d84bd50d 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -20,6 +20,11 @@ public interface IQueryLayerComposer /// FilterExpression GetFilterOnResourceIds(ICollection ids, ResourceContext resourceContext); + /// + /// Builds a join table filter, which matches on the specified IDs. + /// + FilterExpression GetJoinTableFilter(TLeftId leftId, ICollection rightIds, HasManyThroughAttribute relationship); + /// /// Collects constraints and builds a out of them, used to retrieve the actual resources. /// diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 5da08df663..d7c028fb06 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -60,7 +60,31 @@ public FilterExpression GetFilterOnResourceIds(ICollection ids, Resour if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); var baseFilter = GetFilter(Array.Empty(), resourceContext); - return CreateFilterByIds(ids, resourceContext, baseFilter); + + var idAttribute = GetIdAttribute(resourceContext); + return CreateFilterByIds(ids, idAttribute, baseFilter); + } + + /// + public FilterExpression GetJoinTableFilter(TLeftId leftId, ICollection rightIds, + HasManyThroughAttribute relationship) + { + var pseudoLeftIdAttribute = new AttrAttribute + { + Property = relationship.LeftIdProperty, + PublicName = relationship.LeftIdProperty.Name + }; + + var pseudoRightIdAttribute = new AttrAttribute + { + Property = relationship.RightIdProperty, + PublicName = relationship.RightIdProperty.Name + }; + + var leftFilter = CreateFilterByIds(new[] {leftId}, pseudoLeftIdAttribute, null); + var rightFilter = CreateFilterByIds(rightIds, pseudoRightIdAttribute, null); + + return new LogicalExpression(LogicalOperator.And, new[] {leftFilter, rightFilter}); } /// @@ -187,14 +211,15 @@ public QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext { if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); + var idAttribute = GetIdAttribute(resourceContext); + var queryLayer = ComposeFromConstraints(resourceContext); queryLayer.Sort = null; queryLayer.Pagination = null; - queryLayer.Filter = CreateFilterByIds(new[] {id}, resourceContext, queryLayer.Filter); + queryLayer.Filter = CreateFilterByIds(new[] {id}, idAttribute, queryLayer.Filter); if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - var idAttribute = GetIdAttribute(resourceContext); queryLayer.Projection = new Dictionary {{idAttribute, null}}; } else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null) @@ -254,7 +279,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, return new QueryLayer(primaryResourceContext) { Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), - Filter = CreateFilterByIds(new[] {primaryId}, primaryResourceContext, primaryFilter), + Filter = CreateFilterByIds(new[] {primaryId}, primaryIdAttribute, primaryFilter), Projection = primaryProjection }; } @@ -268,10 +293,9 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression r return new IncludeExpression(new[] {parentElement}); } - private FilterExpression CreateFilterByIds(ICollection ids, ResourceContext resourceContext, FilterExpression existingFilter) + private FilterExpression CreateFilterByIds(ICollection ids, AttrAttribute idAttribute, FilterExpression existingFilter) { - var primaryIdAttribute = GetIdAttribute(resourceContext); - var idChain = new ResourceFieldChainExpression(primaryIdAttribute); + var idChain = new ResourceFieldChainExpression(idAttribute); FilterExpression filter = null; @@ -299,11 +323,13 @@ public QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource) var includeElements = _targetedFields.Relationships .Select(relationship => new IncludeElementExpression(relationship)).ToArray(); + var primaryIdAttribute = GetIdAttribute(primaryResource); + var primaryLayer = ComposeTopLayer(Array.Empty(), primaryResource); primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : null; primaryLayer.Sort = null; primaryLayer.Pagination = null; - primaryLayer.Filter = CreateFilterByIds(new[] {id}, primaryResource, primaryLayer.Filter); + primaryLayer.Filter = CreateFilterByIds(new[] {id}, primaryIdAttribute, primaryLayer.Filter); primaryLayer.Projection = null; return primaryLayer; diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index 1204a9fe0d..9ce5d31c1c 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -8,9 +8,8 @@ namespace JsonApiDotNetCore.Repositories public sealed class DataStoreUpdateException : Exception { public DataStoreUpdateException(Exception exception) - : base("Failed to persist changes in the underlying data store.", exception) { } - - public DataStoreUpdateException(string message) - : base(message) { } + : base("Failed to persist changes in the underlying data store.", exception) + { + } } } diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs index 6307c57ffa..11c86aa126 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs @@ -90,7 +90,7 @@ private async Task> GetExistingResourceIds(ICollection attr.Property.Name == nameof(Identifiable.Id)); - var typedIds = resourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); + var typedIds = resourceIds.Select(resource => resource.GetTypedId()).ToArray(); var filter = _queryLayerComposer.GetFilterOnResourceIds(typedIds, resourceContext); var queryLayer = new QueryLayer(resourceContext) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index e7ff4da034..bbf2c24532 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -2,16 +2,13 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; using System.Reflection; using System.Threading.Tasks; -using Humanizer; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; -using JsonApiDotNetCore.Repositories.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; @@ -131,7 +128,7 @@ public virtual async Task CreateAsync(TResource resource) foreach (var relationship in _targetedFields.Relationships) { var rightValue = relationship.GetValue(resource); - await UpdateRelationship(relationship, resource, rightValue); + await UpdateRelationshipAsync(relationship, resource, rightValue); } _dbContext.Set().Add(resource); @@ -170,96 +167,68 @@ private void DetachRelationships(IIdentifiable resource) } /// - public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds) + public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, + FilterExpression joinTableFilter) { _traceWriter.LogMethodStart(new {primaryId, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); var relationship = _targetedFields.Relationships.Single(); - if (relationship is HasManyThroughAttribute hasManyThroughRelationship) + if (relationship is HasManyThroughAttribute hasManyThroughRelationship && joinTableFilter != null) { - // In the case of many-to-many relationships, creating a duplicate entry in the join table results in a unique constraint violation. - await RemoveAlreadyRelatedResourcesFromAssignment(hasManyThroughRelationship, primaryId, secondaryResourceIds); + // 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 ExcludeExistingResourcesFromJoinTableAsync(hasManyThroughRelationship, secondaryResourceIds, joinTableFilter); } - - var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreateResourceWithAssignedId(primaryId)); if (secondaryResourceIds.Any()) { - await UpdateRelationship(relationship, primaryResource, secondaryResourceIds); + var primaryResource = (TResource)_dbContext.GetTrackedOrAttach(CreateResourceWithAssignedId(primaryId)); + + await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); } } - private async Task RemoveAlreadyRelatedResourcesFromAssignment(HasManyThroughAttribute relationship, TId primaryResourceId, ISet secondaryResourceIds) + private async Task ExcludeExistingResourcesFromJoinTableAsync(HasManyThroughAttribute relationship, + ISet secondaryResourceIds, FilterExpression joinTableFilter) { - // TODO: Finalize this. - var throughEntitiesFilter = new ThroughEntitiesFilter(_dbContext, relationship); - var typedRightIds = secondaryResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); - var throughEntities = await throughEntitiesFilter.GetBy(primaryResourceId, typedRightIds); - - // Alternative approaches: - // throughEntities = await GetFilteredThroughEntities_DynamicQueryBuilding(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - // throughEntities = await GetFilteredThroughEntities_QueryBuilderCall(hasManyThroughRelationship, primaryResourceId, secondaryResourceIds); - - var rightResources = throughEntities.Select(ConstructRightResourceOfHasManyRelationship).ToHashSet(); - secondaryResourceIds.ExceptWith(rightResources.ToHashSet()); - - DetachEntities(throughEntities); + dynamic query = CreateJoinTableQuery(relationship, joinTableFilter); + IEnumerable joinTableEntities = await EntityFrameworkQueryableExtensions.ToListAsync(query); + + RemoveEntitiesFromSet(joinTableEntities, secondaryResourceIds, relationship); + + DetachEntities(joinTableEntities.Cast()); } - private IIdentifiable ConstructRightResourceOfHasManyRelationship(object entity) + private IQueryable CreateJoinTableQuery(HasManyThroughAttribute relationship, FilterExpression joinTableFilter) { - var relationship = (HasManyThroughAttribute)_targetedFields.Relationships.Single(); + IQueryable throughEntitySet = _dbContext.Set(relationship.ThroughType); + + var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); + using var scope = scopeFactory.CreateScope(relationship.ThroughType); - var rightResource = _resourceFactory.CreateInstance(relationship.RightType); - rightResource.StringId = relationship.RightIdProperty.GetValue(entity).ToString(); + var whereClauseBuilder = new WhereClauseBuilder(throughEntitySet.Expression, scope, typeof(Queryable)); - return rightResource; + var query = whereClauseBuilder.ApplyWhere(joinTableFilter); + return throughEntitySet.Provider.CreateQuery(query); } - private async Task GetFilteredThroughEntities_DynamicQueryBuilding(TId leftId, ISet rightIds, HasManyThroughAttribute relationship) + private void RemoveEntitiesFromSet(IEnumerable joinTableEntities, ISet secondaryResourceIds, + HasManyThroughAttribute relationship) { - var throughEntityParameter = Expression.Parameter(relationship.ThroughType, relationship.ThroughType.Name.Camelize()); - - var filter = ThroughEntitiesFilter.GetEqualsAndContainsFilter(leftId, rightIds, relationship, throughEntityParameter); + HashSet resourcesToExclude = new HashSet(IdentifiableComparer.Instance); - var predicate = Expression.Lambda(filter, throughEntityParameter); + foreach (var joinTableEntity in joinTableEntities) + { + var resourceToExclude = _resourceFactory.CreateInstance(relationship.RightType); + resourceToExclude.StringId = relationship.RightIdProperty.GetValue(joinTableEntity)?.ToString(); - IQueryable throughSource = _dbContext.Set(relationship.ThroughType); - var whereClause = Expression.Call(typeof(Queryable), nameof(Queryable.Where), new[] { relationship.ThroughType }, throughSource.Expression, predicate); - - dynamic query = throughSource.Provider.CreateQuery(whereClause); - IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); - - return result.Cast().ToArray(); - } - - private async Task GetFilteredThroughEntities_QueryBuilderCall(TId leftId, ISet rightIds, HasManyThroughAttribute relationship) - { - var comparisonTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.LeftIdProperty }); - var comparisionId = new LiteralConstantExpression(leftId.ToString()); - FilterExpression equalsFilter = new ComparisonExpression(ComparisonOperator.Equals, comparisonTargetField, comparisionId); - - var equalsAnyOfTargetField = new ResourceFieldChainExpression(new AttrAttribute { Property = relationship.RightIdProperty }); - var equalsAnyOfIds = rightIds.Select(r => new LiteralConstantExpression(r.ToString())).ToArray(); - FilterExpression containsFilter = new EqualsAnyOfExpression(equalsAnyOfTargetField, equalsAnyOfIds); - - var filter = new LogicalExpression(LogicalOperator.And, new QueryExpression[] { equalsFilter, containsFilter } ); - - IQueryable throughSource = _dbContext.Set(relationship.ThroughType); - - var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); - var scope = scopeFactory.CreateScope(relationship.ThroughType); - - var whereClauseBuilder = new WhereClauseBuilder(throughSource.Expression, scope, typeof(Queryable)); - var whereClause = whereClauseBuilder.ApplyWhere(filter); - - dynamic query = throughSource.Provider.CreateQuery(whereClause); - IEnumerable result = await EntityFrameworkQueryableExtensions.ToListAsync(query); - - return result.Cast().ToArray(); + resourcesToExclude.Add(resourceToExclude); + } + + secondaryResourceIds.ExceptWith(resourcesToExclude); } private TResource CreateResourceWithAssignedId(TId id) @@ -277,7 +246,7 @@ public virtual async Task SetRelationshipAsync(TResource primaryResource, object var relationship = _targetedFields.Relationships.Single(); - await UpdateRelationship(relationship, primaryResource, secondaryResourceIds); + await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIds); await SaveChangesAsync(); } @@ -292,7 +261,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r foreach (var relationship in _targetedFields.Relationships) { var rightResources = relationship.GetValue(resourceFromRequest); - await UpdateRelationship(relationship, resourceFromDatabase, rightResources); + await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightResources); } foreach (var attribute in _targetedFields.Attributes) @@ -376,7 +345,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryRes var rightResources = ((IEnumerable)rightValue).ToHashSet(IdentifiableComparer.Instance); rightResources.ExceptWith(secondaryResourceIds); - await UpdateRelationship(relationship, primaryResource, rightResources); + await UpdateRelationshipAsync(relationship, primaryResource, rightResources); await SaveChangesAsync(); } @@ -399,7 +368,7 @@ protected virtual async Task SaveChangesAsync() } } - private async Task UpdateRelationship(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) + private async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 04ca3bc305..5cc774c23b 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.Repositories @@ -27,7 +28,7 @@ public interface IResourceWriteRepository /// /// Adds resources to a to-many relationship in the underlying data store. /// - Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds); + Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, FilterExpression joinTableFilter); /// /// Updates the attributes and relationships of an existing resource in the underlying data store. diff --git a/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs b/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs deleted file mode 100644 index aec79b170d..0000000000 --- a/src/JsonApiDotNetCore/Repositories/Internal/ThroughEntitiesFilter.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Threading.Tasks; -using Humanizer; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCore.Repositories.Internal -{ - // TODO: Refactor this type (it is a helper method). - internal sealed class ThroughEntitiesFilter - { - private readonly DbContext _dbContext; - private readonly HasManyThroughAttribute _relationship; - - internal ThroughEntitiesFilter(DbContext dbContext, HasManyThroughAttribute relationship) - { - _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); - _relationship = relationship ?? throw new ArgumentNullException(nameof(relationship)); - } - - public async Task GetBy(object primaryId, ISet secondaryIds) - { - - var throughEntityParameter = Expression.Parameter(_relationship.ThroughType, _relationship.ThroughType.Name.Camelize()); - var filter = GetEqualsAndContainsFilter(primaryId, secondaryIds, _relationship, throughEntityParameter); - - dynamic runtimeTypeParameter = TypeHelper.CreateInstance(_relationship.ThroughType); - dynamic @this = this; - - return await @this.GetFilteredEntities(runtimeTypeParameter, throughEntityParameter, filter); - } - - private async Task GetFilteredEntities(TThroughType _, ParameterExpression parameter, Expression filter) where TThroughType : class - { - var predicate = Expression.Lambda>(filter, parameter); - var result = await _dbContext.Set().Where(predicate).ToListAsync(); - - return result.Cast().ToArray(); - } - - internal static Expression GetEqualsAndContainsFilter(object idToEqual, ISet idsToContain, - HasManyThroughAttribute relationship, ParameterExpression parameter) - { - var idEqualsFilter = GetEqualsCall(idToEqual, parameter, relationship.LeftIdProperty); - var containsIdFilter = GetContainsCall(idsToContain, parameter, relationship.RightIdProperty); - - return Expression.AndAlso(idEqualsFilter, containsIdFilter); - } - - internal static MethodCallExpression GetContainsCall(ISet secondaryResourceIds, - ParameterExpression rightEntityParameter, PropertyInfo rightIdProperty) - { - var rightIdMember = Expression.Property(rightEntityParameter, rightIdProperty.Name); - - var idType = rightIdProperty.PropertyType; - var typedIds = TypeHelper.CopyToList(secondaryResourceIds, idType); - var idCollectionConstant = Expression.Constant(typedIds); - - var containsCall = Expression.Call( - typeof(Enumerable), - nameof(Enumerable.Contains), - new[] {idType}, - idCollectionConstant, - rightIdMember); - - return containsCall; - } - - internal static BinaryExpression GetEqualsCall(object id, ParameterExpression rightEntityParameter, - PropertyInfo leftIdProperty) - { - var leftIdMember = Expression.Property(rightEntityParameter, leftIdProperty.Name); - var idConstant = Expression.Constant(id, id.GetType()); - - return Expression.Equal(leftIdMember, idConstant); - } - } -} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 36aec571ed..8569ba003b 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -227,9 +228,14 @@ public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshi if (secondaryResourceIds.Any()) { + var joinTableFilter = _request.Relationship is HasManyThroughAttribute hasManyThrough + ? _queryLayerComposer.GetJoinTableFilter(primaryId, + secondaryResourceIds.Select(x => x.GetTypedId()).ToArray(), hasManyThrough) + : null; + try { - await _repository.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds); + await _repository.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, joinTableFilter); } catch (DataStoreUpdateException) { diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 2e68031414..82aae80866 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -224,7 +224,7 @@ private sealed class IntResourceRepository : IResourceRepository public Task> GetAsync(QueryLayer layer) => throw new NotImplementedException(); public Task CountAsync(FilterExpression topFilter) => throw new NotImplementedException(); public Task CreateAsync(IntResource resource) => throw new NotImplementedException(); - public Task AddToToManyRelationshipAsync(int primaryId, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int primaryId, ISet secondaryResourceIds, FilterExpression joinTableFilter) => throw new NotImplementedException(); public Task UpdateAsync(IntResource resourceFromRequest, IntResource resourceFromDatabase) => throw new NotImplementedException(); public Task SetRelationshipAsync(IntResource primaryResource, object secondaryResourceIds) => throw new NotImplementedException(); public Task DeleteAsync(int id) => throw new NotImplementedException(); @@ -237,7 +237,7 @@ private sealed class GuidResourceRepository : IResourceRepository> GetAsync(QueryLayer layer) => throw new NotImplementedException(); public Task CountAsync(FilterExpression topFilter) => throw new NotImplementedException(); public Task CreateAsync(GuidResource resource) => throw new NotImplementedException(); - public Task AddToToManyRelationshipAsync(Guid primaryId, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid primaryId, ISet secondaryResourceIds, FilterExpression joinTableFilter) => throw new NotImplementedException(); public Task UpdateAsync(GuidResource resourceFromRequest, GuidResource resourceFromDatabase) => throw new NotImplementedException(); public Task SetRelationshipAsync(GuidResource primaryResource, object secondaryResourceIds) => throw new NotImplementedException(); public Task DeleteAsync(Guid id) => throw new NotImplementedException(); From cd57383a200eb1285fc739bbb589f6d0045dc869 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 16:31:55 +0100 Subject: [PATCH 14/24] Renames --- .../Services/JsonApiResourceService.cs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 8569ba003b..89b23d93e6 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -109,7 +109,7 @@ public virtual async Task GetAsync(TId id) public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { _traceWriter.LogMethodStart(new {id, relationshipName}); - AssertRelationshipExists(relationshipName); + AssertHasRelationship(_request.Relationship, relationshipName); _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); @@ -148,7 +148,7 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh _traceWriter.LogMethodStart(new {id, relationshipName}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - AssertRelationshipExists(relationshipName); + AssertHasRelationship(_request.Relationship, relationshipName); _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); @@ -223,8 +223,8 @@ public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshi if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); - AssertRelationshipExists(relationshipName); - AssertRelationshipIsToMany(); + AssertHasRelationship(_request.Relationship, relationshipName); + AssertRelationshipIsToMany(_request.Relationship); if (secondaryResourceIds.Any()) { @@ -297,7 +297,7 @@ public virtual async Task SetRelationshipAsync(TId primaryId, string relationshi _traceWriter.LogMethodStart(new {primaryId, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - AssertRelationshipExists(relationshipName); + AssertHasRelationship(_request.Relationship, relationshipName); TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId); @@ -348,8 +348,8 @@ public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relati if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); - AssertRelationshipExists(relationshipName); - AssertRelationshipIsToMany(); + AssertHasRelationship(_request.Relationship, relationshipName); + AssertRelationshipIsToMany(_request.Relationship); TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId); await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); @@ -385,17 +385,16 @@ private void AssertPrimaryResourceExists(TResource resource) } } - private void AssertRelationshipExists(string relationshipName) + private void AssertHasRelationship(RelationshipAttribute relationship, string name) { - if (_request.Relationship == null) + if (relationship == null) { - throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); + throw new RelationshipNotFoundException(name, _request.PrimaryResource.PublicName); } } - private void AssertRelationshipIsToMany() + private void AssertRelationshipIsToMany(RelationshipAttribute relationship) { - var relationship = _request.Relationship; if (!(relationship is HasManyAttribute)) { throw new ToManyRelationshipRequiredException(relationship.PublicName); From 5b8b94774c418d5fed25f8c98c6cc715b4fec395 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 17:49:19 +0100 Subject: [PATCH 15/24] Extract responsibilities --- .../Services/CustomArticleService.cs | 4 +- .../JsonApiApplicationBuilder.cs | 2 +- .../ISecondaryResourceResolver.cs | 17 +++++++ ...pector.cs => SecondaryResourceResolver.cs} | 41 ++++++--------- .../Services/JsonApiResourceService.cs | 50 ++++++++++++------- .../ServiceDiscoveryFacadeTests.cs | 6 +-- .../Services/DefaultResourceService_Tests.cs | 2 +- 7 files changed, 72 insertions(+), 50 deletions(-) create mode 100644 src/JsonApiDotNetCore/Repositories/ISecondaryResourceResolver.cs rename src/JsonApiDotNetCore/Repositories/{DataStoreUpdateFailureInspector.cs => SecondaryResourceResolver.cs} (72%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 692fa235ab..7876fbb95f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -22,10 +22,10 @@ public CustomArticleService( IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, + ISecondaryResourceResolver secondaryResourceResolver, IResourceHookExecutorFacade hookExecutor) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, - request, resourceChangeTracker, resourceFactory, dataStoreUpdateFailureInspector, + request, resourceChangeTracker, resourceFactory, secondaryResourceResolver, hookExecutor) { } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index a1b8e455aa..a6281f38a3 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -146,7 +146,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.AddScoped(); _services.AddScoped(); _services.TryAddScoped(); - _services.AddScoped(); + _services.AddScoped(); } private void AddMiddlewareLayer() diff --git a/src/JsonApiDotNetCore/Repositories/ISecondaryResourceResolver.cs b/src/JsonApiDotNetCore/Repositories/ISecondaryResourceResolver.cs new file mode 100644 index 0000000000..7468f6a091 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/ISecondaryResourceResolver.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Provides methods to retrieve unassigned related resources using its matching repository. + /// + public interface ISecondaryResourceResolver + { + Task> GetMissingResourcesToAssignInRelationships(IIdentifiable leftResource); + Task> GetMissingSecondaryResources(RelationshipAttribute relationship, ICollection rightResourceIds); + } +} diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs b/src/JsonApiDotNetCore/Repositories/SecondaryResourceResolver.cs similarity index 72% rename from src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs rename to src/JsonApiDotNetCore/Repositories/SecondaryResourceResolver.cs index 11c86aa126..a415c9ca07 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateFailureInspector.cs +++ b/src/JsonApiDotNetCore/Repositories/SecondaryResourceResolver.cs @@ -11,20 +11,14 @@ namespace JsonApiDotNetCore.Repositories { - public interface IDataStoreUpdateFailureInspector - { - Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable leftResource); - Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, ICollection rightResourceIds); - } - - internal sealed class DataStoreUpdateFailureInspector : IDataStoreUpdateFailureInspector + internal sealed class SecondaryResourceResolver : ISecondaryResourceResolver { private readonly IResourceContextProvider _resourceContextProvider; private readonly ITargetedFields _targetedFields; private readonly IQueryLayerComposer _queryLayerComposer; private readonly IResourceRepositoryAccessor _resourceRepositoryAccessor; - public DataStoreUpdateFailureInspector(IResourceContextProvider resourceContextProvider, + public SecondaryResourceResolver(IResourceContextProvider resourceContextProvider, ITargetedFields targetedFields, IQueryLayerComposer queryLayerComposer, IResourceRepositoryAccessor resourceRepositoryAccessor) { @@ -34,7 +28,7 @@ public DataStoreUpdateFailureInspector(IResourceContextProvider resourceContextP _resourceRepositoryAccessor = resourceRepositoryAccessor ?? throw new ArgumentNullException(nameof(resourceRepositoryAccessor)); } - public async Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable leftResource) + public async Task> GetMissingResourcesToAssignInRelationships(IIdentifiable leftResource) { var missingResources = new List(); @@ -47,20 +41,12 @@ public async Task AssertRightResourcesInRelationshipsExistAsync(IIdentifiable le await missingResources.AddRangeAsync(missingResourcesInRelationship); } - if (missingResources.Any()) - { - throw new ResourcesInRelationshipsNotFoundException(missingResources); - } + return missingResources; } - public async Task AssertRightResourcesInRelationshipExistAsync(RelationshipAttribute relationship, - ICollection rightResourceIds) + public async Task> GetMissingSecondaryResources(RelationshipAttribute relationship, ICollection rightResourceIds) { - var missingResources = await GetMissingRightResourcesAsync(rightResourceIds, relationship).ToListAsync(); - if (missingResources.Any()) - { - throw new ResourcesInRelationshipsNotFoundException(missingResources); - } + return await GetMissingRightResourcesAsync(rightResourceIds, relationship).ToListAsync(); } private async IAsyncEnumerable GetMissingRightResourcesAsync( @@ -81,19 +67,27 @@ private async IAsyncEnumerable GetMissingRightRes } } - private async Task> GetExistingResourceIds(ICollection resourceIds, ResourceContext resourceContext) + public async Task> GetExistingResourceIds(ICollection resourceIds, ResourceContext resourceContext) { if (!resourceIds.Any()) { return Array.Empty(); } + var queryLayer = CreateQueryLayerForResourceIds(resourceIds, resourceContext); + + var resources = await _resourceRepositoryAccessor.GetAsync(resourceContext.ResourceType, queryLayer); + return resources.Select(resource => resource.StringId).ToArray(); + } + + private QueryLayer CreateQueryLayerForResourceIds(IEnumerable resourceIds, ResourceContext resourceContext) + { var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); var typedIds = resourceIds.Select(resource => resource.GetTypedId()).ToArray(); var filter = _queryLayerComposer.GetFilterOnResourceIds(typedIds, resourceContext); - var queryLayer = new QueryLayer(resourceContext) + return new QueryLayer(resourceContext) { Filter = filter, Projection = new Dictionary @@ -101,9 +95,6 @@ private async Task> GetExistingResourceIds(ICollection resource.StringId).ToArray(); } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 89b23d93e6..614dbcaa7f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -30,7 +30,7 @@ public class JsonApiResourceService : private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; private readonly IResourceFactory _resourceFactory; - private readonly IDataStoreUpdateFailureInspector _dataStoreUpdateFailureInspector; + private readonly ISecondaryResourceResolver _secondaryResourceResolver; private readonly IResourceHookExecutorFacade _hookExecutor; public JsonApiResourceService( @@ -42,7 +42,7 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, + ISecondaryResourceResolver secondaryResourceResolver, IResourceHookExecutorFacade hookExecutor) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -55,7 +55,7 @@ public JsonApiResourceService( _request = request ?? throw new ArgumentNullException(nameof(request)); _resourceChangeTracker = resourceChangeTracker ?? throw new ArgumentNullException(nameof(resourceChangeTracker)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); - _dataStoreUpdateFailureInspector = dataStoreUpdateFailureInspector ?? throw new ArgumentNullException(nameof(dataStoreUpdateFailureInspector)); + _secondaryResourceResolver = secondaryResourceResolver ?? throw new ArgumentNullException(nameof(secondaryResourceResolver)); _hookExecutor = hookExecutor ?? throw new ArgumentNullException(nameof(hookExecutor)); } @@ -173,19 +173,18 @@ public virtual async Task CreateAsync(TResource resource) _traceWriter.LogMethodStart(new {resource}); if (resource == null) throw new ArgumentNullException(nameof(resource)); - var resourceFromRequest = resource; - _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + _resourceChangeTracker.SetRequestedAttributeValues(resource); var defaultResource = _resourceFactory.CreateInstance(); defaultResource.Id = resource.Id; _resourceChangeTracker.SetInitiallyStoredAttributeValues(defaultResource); - _hookExecutor.BeforeCreate(resourceFromRequest); + _hookExecutor.BeforeCreate(resource); try { - await _repository.CreateAsync(resourceFromRequest); + await _repository.CreateAsync(resource); } catch (DataStoreUpdateException) { @@ -195,11 +194,11 @@ public virtual async Task CreateAsync(TResource resource) throw new ResourceAlreadyExistsException(resource.StringId, _request.PrimaryResource.PublicName); } - await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipsExistAsync(resourceFromRequest); + await AssertResourcesToAssignInRelationshipsExistAsync(resource); throw; } - var resourceFromDatabase = await TryGetPrimaryResourceByIdAsync(resourceFromRequest.Id, TopFieldSelection.WithAllAttributes); + var resourceFromDatabase = await TryGetPrimaryResourceByIdAsync(resource.Id, TopFieldSelection.WithAllAttributes); AssertPrimaryResourceExists(resourceFromDatabase); _hookExecutor.AfterCreate(resourceFromDatabase); @@ -216,6 +215,15 @@ public virtual async Task CreateAsync(TResource resource) return resourceFromDatabase; } + private async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource resource) + { + var missingResources = await _secondaryResourceResolver.GetMissingResourcesToAssignInRelationships(resource); + if (missingResources.Any()) + { + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } + } + /// public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds) { @@ -242,13 +250,21 @@ public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshi var primaryResource = await TryGetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); - await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); - + await AssertResourcesExistAsync(secondaryResourceIds); throw; } } } + private async Task AssertResourcesExistAsync(ICollection secondaryResourceIds) + { + var missingResources = await _secondaryResourceResolver.GetMissingSecondaryResources(_request.Relationship, secondaryResourceIds); + if (missingResources.Any()) + { + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } + } + /// public virtual async Task UpdateAsync(TId id, TResource resource) { @@ -270,7 +286,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) } catch (DataStoreUpdateException) { - await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipsExistAsync(resourceFromRequest); + await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest); throw; } @@ -309,8 +325,7 @@ public virtual async Task SetRelationshipAsync(TId primaryId, string relationshi } catch (DataStoreUpdateException) { - await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, TypeHelper.ExtractResources(secondaryResourceIds)); - + await AssertResourcesExistAsync(TypeHelper.ExtractResources(secondaryResourceIds)); throw; } @@ -334,7 +349,6 @@ await _hookExecutor.BeforeDeleteAsync(id, async () => { var primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); AssertPrimaryResourceExists(primaryResource); - throw; } @@ -352,7 +366,7 @@ public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relati AssertRelationshipIsToMany(_request.Relationship); TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId); - await _dataStoreUpdateFailureInspector.AssertRightResourcesInRelationshipExistAsync(_request.Relationship, secondaryResourceIds); + await AssertResourcesExistAsync(secondaryResourceIds); if (secondaryResourceIds.Any()) { @@ -419,10 +433,10 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, + ISecondaryResourceResolver secondaryResourceResolver, IResourceHookExecutorFacade hookExecutor) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, dataStoreUpdateFailureInspector, hookExecutor) + resourceChangeTracker, resourceFactory, secondaryResourceResolver, hookExecutor) { } } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 0c9a24be6a..337954780f 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -41,7 +41,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(_options, NullLoggerFactory.Instance); } @@ -156,10 +156,10 @@ public TestModelService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IDataStoreUpdateFailureInspector dataStoreUpdateFailureInspector, + ISecondaryResourceResolver secondaryResourceResolver, IResourceHookExecutorFacade hookExecutor) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, dataStoreUpdateFailureInspector, + resourceChangeTracker, resourceFactory, secondaryResourceResolver, hookExecutor) { } diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index 8e3b4dd125..5869b76cff 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -82,7 +82,7 @@ private JsonApiResourceService GetService() var resourceContextProvider = new Mock().Object; var resourceHookExecutor = new NeverResourceHookExecutorFacade(); var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext, targetedFields); - var dataStoreUpdateFailureInspector = new DataStoreUpdateFailureInspector(resourceContextProvider, targetedFields, composer, resourceRepositoryAccessor); + var dataStoreUpdateFailureInspector = new SecondaryResourceResolver(resourceContextProvider, targetedFields, composer, resourceRepositoryAccessor); var request = new JsonApiRequest { From 9f675d06f2c63d80068d988e3e6cfbeaa3f504bb Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 17:57:29 +0100 Subject: [PATCH 16/24] Added tracking TODOs --- .../Repositories/EntityFrameworkCoreRepository.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index bbf2c24532..b2d3d8ced8 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -188,6 +188,8 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet @@ -293,8 +296,9 @@ public virtual async Task DeleteAsync(TId id) } _dbContext.Remove(resource); - await SaveChangesAsync(); + + // TODO: Do we need to flush cache here? } private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) @@ -347,6 +351,8 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryRes await UpdateRelationshipAsync(relationship, primaryResource, rightResources); await SaveChangesAsync(); + + // TODO: Do we need to flush cache here? } /// From 6ff1b51598c3f274b9489b54edf90b07e812c694 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 18:00:36 +0100 Subject: [PATCH 17/24] formatting --- .../EntityFrameworkCoreRepository.cs | 43 ++++++++++--------- .../Services/JsonApiResourceService.cs | 11 ++--- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index b2d3d8ced8..81e3c8ae61 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -52,7 +52,7 @@ public EntityFrameworkCoreRepository( _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); - + _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -139,10 +139,10 @@ public virtual async Task CreateAsync(TResource resource) protected void FlushFromCache(IIdentifiable resource) { - resource = (IIdentifiable)_dbContext.GetTrackedIdentifiable(resource); + resource = (IIdentifiable) _dbContext.GetTrackedIdentifiable(resource); if (resource != null) { - DetachEntities(new [] { resource }); + DetachEntities(new[] {resource}); DetachRelationships(resource); } } @@ -161,7 +161,7 @@ private void DetachRelationships(IIdentifiable resource) { var rightValue = relationship.GetValue(resource); var rightResources = TypeHelper.ExtractResources(rightValue); - + DetachEntities(rightResources); } } @@ -184,7 +184,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet()) { @@ -343,12 +343,12 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryRes _traceWriter.LogMethodStart(new {primaryResource, secondaryResourceIds}); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); - var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + var relationship = (HasManyAttribute) _targetedFields.Relationships.Single(); var rightValue = relationship.GetValue(primaryResource); - var rightResources = ((IEnumerable)rightValue).ToHashSet(IdentifiableComparer.Instance); + var rightResources = ((IEnumerable) rightValue).ToHashSet(IdentifiableComparer.Instance); rightResources.ExceptWith(secondaryResourceIds); - + await UpdateRelationshipAsync(relationship, primaryResource, rightResources); await SaveChangesAsync(); @@ -377,20 +377,20 @@ protected virtual async Task SaveChangesAsync() private async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); - + if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) { - var entityEntry = _dbContext.Entry(trackedValueToAssign); + var entityEntry = _dbContext.Entry(trackedValueToAssign); var inversePropertyName = relationship.InverseNavigationProperty.Name; - + await entityEntry.Reference(inversePropertyName).LoadAsync(); } - + if (IsHasOneWithForeignKeyAtLeftSide(relationship) && trackedValueToAssign == null) { PrepareChangeTrackerForNullAssignment(relationship, leftResource); } - + relationship.SetValue(leftResource, trackedValueToAssign); } @@ -455,8 +455,8 @@ private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relatio relationship.SetValue(leftResource, placeholderRightResource); _dbContext.Entry(leftResource).DetectChanges(); - - DetachEntities(new [] { placeholderRightResource }); + + DetachEntities(new[] {placeholderRightResource}); } private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) @@ -475,7 +475,7 @@ private void EnsurePrimaryKeyPropertiesAreNotNull(object entity) private static object GetNonNullValueForProperty(PropertyInfo propertyInfo) { var propertyType = propertyInfo.PropertyType; - + if (propertyType == typeof(string)) { return string.Empty; @@ -504,13 +504,14 @@ public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepos where TResource : class, IIdentifiable { public EntityFrameworkCoreRepository( - ITargetedFields targetedFields, - IDbContextResolver contextResolver, + ITargetedFields targetedFields, + IDbContextResolver contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { } + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { + } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 614dbcaa7f..1f62541909 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -125,7 +125,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN } var primaryResources = await _repository.GetAsync(primaryLayer); - + var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); @@ -227,7 +227,7 @@ private async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource re /// public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new { primaryId, secondaryResourceIds }); + _traceWriter.LogMethodStart(new {primaryId, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); @@ -310,7 +310,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) /// public virtual async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {primaryId, relationshipName, secondaryResourceIds}); + _traceWriter.LogMethodStart(new {primaryId, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertHasRelationship(_request.Relationship, relationshipName); @@ -386,7 +386,7 @@ private async Task GetPrimaryResourceForUpdateAsync(TId id) { var queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); var resource = await _repository.GetForUpdateAsync(queryLayer); - + AssertPrimaryResourceExists(resource); return resource; } @@ -437,6 +437,7 @@ public JsonApiResourceService( IResourceHookExecutorFacade hookExecutor) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceFactory, secondaryResourceResolver, hookExecutor) - { } + { + } } } From b0926865182e895c141713ce6f82e28f1ef82e65 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 18:01:46 +0100 Subject: [PATCH 18/24] TODO --- src/JsonApiDotNetCore/Services/JsonApiResourceService.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 1f62541909..96ca86976f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -17,6 +17,8 @@ namespace JsonApiDotNetCore.Services { + // TODO: Add write operations to our documentation. + /// public class JsonApiResourceService : IResourceService From ddfad48f2225a927fcaff5b33dc0288aca15857b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 19:01:16 +0100 Subject: [PATCH 19/24] Added tests for obfuscated IDs --- .../Controllers/PassportsController.cs | 38 +- .../Models/Passport.cs | 10 - .../Controllers/BaseJsonApiController.cs | 2 +- .../Resources/Identifiable.cs | 2 +- .../Building/ResourceObjectBuilder.cs | 2 +- .../Serialization/JsonApiReader.cs | 4 +- .../Acceptance/InjectableResourceTests.cs | 5 +- .../IntegrationTests/FakerContainer.cs | 73 +++ .../IntegrationTests/Filtering/FilterTests.cs | 83 --- .../IdObfuscation/BankAccount.cs | 14 + .../IdObfuscation/BankAccountsController.cs | 15 + .../IdObfuscation/DebitCard.cs | 16 + .../IdObfuscation/DebitCardsController.cs | 15 + .../IdObfuscation/HexadecimalCodec.cs | 21 +- .../IdObfuscation/IdObfuscationTests.cs | 476 ++++++++++++++++++ .../IdObfuscation/ObfuscatedIdentifiable.cs | 17 + .../ObfuscatedIdentifiableController.cs | 92 ++++ .../IdObfuscation/ObfuscationDbContext.cs | 15 + .../IdObfuscation/ObfuscationFakers.cs | 22 + .../IntegrationTests/Writing/WriteFakers.cs | 68 +-- 20 files changed, 779 insertions(+), 211 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs rename src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs => test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs (69%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs index 152628ad96..62fa1e96c3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs @@ -1,50 +1,16 @@ -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - public sealed class PassportsController : BaseJsonApiController + public sealed class PassportsController : JsonApiController { - public PassportsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) + public PassportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public override async Task GetAsync() => await base.GetAsync(); - - [HttpGet("{id}")] - public async Task GetAsync(string id) - { - int idValue = HexadecimalObfuscationCodec.Decode(id); - return await base.GetAsync(idValue); - } - - [HttpPatch("{id}")] - public async Task PatchAsync(string id, [FromBody] Passport resource) - { - int idValue = HexadecimalObfuscationCodec.Decode(id); - return await base.PatchAsync(idValue, resource); - } - - [HttpPost] - public override async Task PostAsync([FromBody] Passport resource) - { - return await base.PostAsync(resource); - } - - [HttpDelete("{id}")] - public async Task DeleteAsync(string id) { - int idValue = HexadecimalObfuscationCodec.Decode(id); - return await base.DeleteAsync(idValue); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index c66d74874e..58eaeb5f9f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -14,16 +14,6 @@ public class Passport : Identifiable private readonly ISystemClock _systemClock; private int? _socialSecurityNumber; - protected override string GetStringId(int value) - { - return HexadecimalObfuscationCodec.Encode(value); - } - - protected override int GetTypedId(string value) - { - return HexadecimalObfuscationCodec.Decode(value); - } - [Attr] public int? SocialSecurityNumber { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 616dce670e..310495d499 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -160,7 +160,7 @@ public virtual async Task PostAsync([FromBody] TResource resource if (_create == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - if (!_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) + if (!_options.AllowClientGeneratedIds && resource.StringId != null) throw new ResourceIdInPostRequestNotAllowedException(); if (_options.ValidateModelState && !ModelState.IsValid) diff --git a/src/JsonApiDotNetCore/Resources/Identifiable.cs b/src/JsonApiDotNetCore/Resources/Identifiable.cs index b621869e80..532e93edcb 100644 --- a/src/JsonApiDotNetCore/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore/Resources/Identifiable.cs @@ -37,7 +37,7 @@ protected virtual string GetStringId(TId value) /// protected virtual TId GetTypedId(string value) { - return string.IsNullOrEmpty(value) ? default : (TId)TypeHelper.ConvertType(value, typeof(TId)); + return value == null ? default : (TId)TypeHelper.ConvertType(value, typeof(TId)); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index fbf154ec51..a958610b58 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -29,7 +29,7 @@ public virtual ResourceObject Build(IIdentifiable resource, IReadOnlyCollection< var resourceContext = ResourceContextProvider.GetResourceContext(resource.GetType()); // populating the top-level "type" and "id" members. - var resourceObject = new ResourceObject { Type = resourceContext.PublicName, Id = resource.StringId == string.Empty ? null : resource.StringId }; + var resourceObject = new ResourceObject { Type = resourceContext.PublicName, Id = resource.StringId }; // populating the top-level "attribute" member of a resource object. never include "id" as an attribute if (attributes != null && (attributes = attributes.Where(attr => attr.Property.Name != nameof(Identifiable.Id)).ToArray()).Any()) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 23de159cef..b433b4736a 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -180,7 +180,7 @@ private void ValidatePrimaryIdValue(object model, PathString requestPath) /// private bool HasMissingId(object model) { - return TryGetId(model, out string id) && string.IsNullOrEmpty(id); + return TryGetId(model, out string id) && id == null; } /// @@ -190,7 +190,7 @@ private bool HasMissingId(IEnumerable models) { foreach (var model in models) { - if (TryGetId(model, out string id) && string.IsNullOrEmpty(id)) + if (TryGetId(model, out string id) && id == null) { return true; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index ec519a898b..8ab3b67a0c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -200,9 +200,8 @@ public async Task Can_Get_Passports_With_Sparse_Fieldset() public async Task Fail_When_Deleting_Missing_Passport() { // Arrange - string passportId = HexadecimalObfuscationCodec.Encode(1234567890); - var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/passports/" + passportId); + var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/passports/1234567890"); // Act var response = await _fixture.Client.SendAsync(request); @@ -215,7 +214,7 @@ public async Task Fail_When_Deleting_Missing_Passport() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'passports' with ID '" + passportId + "' does not exist.", errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'passports' with ID '1234567890' does not exist.", errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs new file mode 100644 index 0000000000..50ce77c416 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests +{ + internal abstract class FakerContainer + { + protected static int GetFakerSeed() + { + // The goal here is to have stable data over multiple test runs, but at the same time different data per test case. + + MethodBase testMethod = GetTestMethod(); + var testName = testMethod.DeclaringType?.FullName + "." + testMethod.Name; + + return GetDeterministicHashCode(testName); + } + + private static MethodBase GetTestMethod() + { + var stackTrace = new StackTrace(); + + var testMethod = stackTrace.GetFrames() + .Select(stackFrame => stackFrame?.GetMethod()) + .FirstOrDefault(IsTestMethod); + + if (testMethod == null) + { + // If called after the first await statement, the test method is no longer on the stack, + // but has been replaced with the compiler-generated async/wait state machine. + throw new InvalidOperationException("Fakers can only be used from within (the start of) a test method."); + } + + return testMethod; + } + + private static bool IsTestMethod(MethodBase method) + { + if (method == null) + { + return false; + } + + return method.GetCustomAttribute(typeof(FactAttribute)) != null || method.GetCustomAttribute(typeof(TheoryAttribute)) != null; + } + + private static int GetDeterministicHashCode(string source) + { + // https://andrewlock.net/why-is-string-gethashcode-different-each-time-i-run-my-program-in-net-core/ + unchecked + { + int hash1 = (5381 << 16) + 5381; + int hash2 = hash1; + + for (int i = 0; i < source.Length; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ source[i]; + + if (i == source.Length - 1) + { + break; + } + + hash2 = ((hash2 << 5) + hash2) ^ source[i + 1]; + } + + return hash1 + hash2 * 1566083941; + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs index b94e3d9b45..6c4703f71d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; @@ -110,87 +109,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Id.Should().Be(person.StringId); responseDocument.ManyData[0].Attributes["firstName"].Should().Be(person.FirstName); } - - [Fact] - public async Task Can_filter_on_obfuscated_ID() - { - // Arrange - Passport passport = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - passport = new Passport(dbContext) - { - SocialSecurityNumber = 123, - BirthCountry = new Country() - }; - - await dbContext.ClearTableAsync(); - dbContext.Passports.AddRange(passport, new Passport(dbContext)); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/passports?filter=equals(id,'{passport.StringId}')"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(passport.StringId); - responseDocument.ManyData[0].Attributes["socialSecurityNumber"].Should().Be(passport.SocialSecurityNumber); - } - - [Fact] - public async Task Can_filter_in_set_on_obfuscated_ID() - { - // Arrange - var passports = new List(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - passports.AddRange(new[] - { - new Passport(dbContext) - { - SocialSecurityNumber = 123, - BirthCountry = new Country() - }, - new Passport(dbContext) - { - SocialSecurityNumber = 456, - BirthCountry = new Country() - }, - new Passport(dbContext) - { - BirthCountry = new Country() - } - }); - - await dbContext.ClearTableAsync(); - dbContext.Passports.AddRange(passports); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/passports?filter=any(id,'{passports[0].StringId}','{passports[1].StringId}')"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - - responseDocument.ManyData[0].Id.Should().Be(passports[0].StringId); - responseDocument.ManyData[0].Attributes["socialSecurityNumber"].Should().Be(passports[0].SocialSecurityNumber); - - responseDocument.ManyData[1].Id.Should().Be(passports[1].StringId); - responseDocument.ManyData[1].Attributes["socialSecurityNumber"].Should().Be(passports[1].SocialSecurityNumber); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs new file mode 100644 index 0000000000..d2f33c7dc7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class BankAccount : ObfuscatedIdentifiable + { + [Attr] + public string Iban { get; set; } + + [HasMany] + public IList Cards { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs new file mode 100644 index 0000000000..91793dfc8c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class BankAccountsController : ObfuscatedIdentifiableController + { + public BankAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs new file mode 100644 index 0000000000..9bd4bcc789 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class DebitCard : ObfuscatedIdentifiable + { + [Attr] + public string OwnerName { get; set; } + + [Attr] + public short PinCode { get; set; } + + [HasOne] + public BankAccount Account { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs new file mode 100644 index 0000000000..b72cea109e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class DebitCardsController : ObfuscatedIdentifiableController + { + public DebitCardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs similarity index 69% rename from src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index 27fa9256c0..96c4642f4f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs @@ -1,22 +1,29 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Net; using System.Text; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCoreExample +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation { - public static class HexadecimalObfuscationCodec + internal static class HexadecimalCodec { public static int Decode(string value) { - if (string.IsNullOrEmpty(value)) + if (value == null) { return 0; } if (!value.StartsWith("x")) { - throw new InvalidOperationException("Invalid obfuscated id."); + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Invalid ID value.", + Detail = $"The value '{value}' is not a valid hexadecimal value." + }); } string stringValue = FromHexString(value.Substring(1)); @@ -37,11 +44,11 @@ private static string FromHexString(string hexString) return new string(chars); } - public static string Encode(object value) + public static string Encode(int value) { - if (value is int intValue && intValue == 0) + if (value == 0) { - return string.Empty; + return null; } string stringValue = value.ToString(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs new file mode 100644 index 0000000000..811f58a7a3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -0,0 +1,476 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class IdObfuscationTests + : IClassFixture, ObfuscationDbContext>> + { + private readonly IntegrationTestContext, ObfuscationDbContext> _testContext; + private readonly ObfuscationFakers _fakers = new ObfuscationFakers(); + + public IdObfuscationTests(IntegrationTestContext, ObfuscationDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_filter_equality_in_primary_resources() + { + // Arrange + var bankAccounts = _fakers.BankAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.BankAccounts.AddRange(bankAccounts); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts?filter=equals(id,'{bankAccounts[1].StringId}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(bankAccounts[1].StringId); + } + + [Fact] + public async Task Can_filter_any_in_primary_resources() + { + // Arrange + var bankAccounts = _fakers.BankAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.BankAccounts.AddRange(bankAccounts); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts?filter=any(id,'{bankAccounts[1].StringId}','{HexadecimalCodec.Encode(99999999)}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(bankAccounts[1].StringId); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_invalid_ID() + { + // Arrange + var route = "/bankAccounts/not-a-hex-value"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Invalid ID value."); + responseDocument.Errors[0].Detail.Should().Be("The value 'not-a-hex-value' is not a valid hexadecimal value."); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var debitCard = _fakers.DebitCard.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DebitCards.Add(debitCard); + await dbContext.SaveChangesAsync(); + }); + + var route = "/debitCards/" + debitCard.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(debitCard.StringId); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + var bankAccount = _fakers.BankAccount.Generate(); + bankAccount.Cards = _fakers.DebitCard.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(bankAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts/{bankAccount.StringId}/cards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(bankAccount.Cards[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(bankAccount.Cards[1].StringId); + } + + [Fact] + public async Task Can_include_resource_with_sparse_fieldset() + { + // Arrange + var bankAccount = _fakers.BankAccount.Generate(); + bankAccount.Cards = _fakers.DebitCard.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(bankAccount); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts/{bankAccount.StringId}?include=cards&fields[cards]=ownerName"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(bankAccount.StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Id.Should().Be(bankAccount.Cards[0].StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + } + + [Fact] + public async Task Can_get_relationship() + { + // Arrange + var bankAccount = _fakers.BankAccount.Generate(); + bankAccount.Cards = _fakers.DebitCard.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(bankAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts/{bankAccount.StringId}/relationships/cards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(bankAccount.Cards[0].StringId); + } + + [Fact] + public async Task Can_create_resource_with_relationship() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + var newDebitCard = _fakers.DebitCard.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(existingBankAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "debitCards", + attributes = new + { + ownerName = newDebitCard.OwnerName, + pinCode = newDebitCard.PinCode + }, + relationships = new + { + account = new + { + data = new + { + type = "bankAccounts", + id = existingBankAccount.StringId + } + } + } + } + }; + + var route = "/debitCards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Attributes["ownerName"].Should().Be(newDebitCard.OwnerName); + responseDocument.SingleData.Attributes["pinCode"].Should().Be(newDebitCard.PinCode); + + var newDebitCardId = HexadecimalCodec.Decode(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var debitCardInDatabase = await dbContext.DebitCards + .Include(debitCard => debitCard.Account) + .FirstAsync(debitCard => debitCard.Id == newDebitCardId); + + debitCardInDatabase.OwnerName.Should().Be(newDebitCard.OwnerName); + debitCardInDatabase.PinCode.Should().Be(newDebitCard.PinCode); + + debitCardInDatabase.Account.Should().NotBeNull(); + debitCardInDatabase.Account.Id.Should().Be(existingBankAccount.Id); + debitCardInDatabase.Account.StringId.Should().Be(existingBankAccount.StringId); + }); + } + + [Fact] + public async Task Can_update_resource_with_relationship() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + existingBankAccount.Cards = _fakers.DebitCard.Generate(1); + + var existingDebitCard = _fakers.DebitCard.Generate(); + + var newIban = _fakers.BankAccount.Generate().Iban; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingBankAccount, existingDebitCard); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "bankAccounts", + id = existingBankAccount.StringId, + attributes = new + { + iban = newIban + }, + relationships = new + { + cards = new + { + data = new[] + { + new + { + type = "debitCards", + id = existingDebitCard.StringId + } + } + } + } + } + }; + + var route = "/bankAccounts/" + existingBankAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var bankAccountInDatabase = await dbContext.BankAccounts + .Include(bankAccount => bankAccount.Cards) + .FirstAsync(bankAccount => bankAccount.Id == existingBankAccount.Id); + + bankAccountInDatabase.Iban.Should().Be(newIban); + + bankAccountInDatabase.Cards.Should().HaveCount(1); + bankAccountInDatabase.Cards[0].Id.Should().Be(existingDebitCard.Id); + bankAccountInDatabase.Cards[0].StringId.Should().Be(existingDebitCard.StringId); + }); + + } + + [Fact] + public async Task Can_add_to_ToMany_relationship() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + existingBankAccount.Cards = _fakers.DebitCard.Generate(1); + + var existingDebitCard = _fakers.DebitCard.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingBankAccount, existingDebitCard); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "debitCards", + id = existingDebitCard.StringId + } + } + }; + + var route = $"/bankAccounts/{existingBankAccount.StringId}/relationships/cards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var bankAccountInDatabase = await dbContext.BankAccounts + .Include(bankAccount => bankAccount.Cards) + .FirstAsync(bankAccount => bankAccount.Id == existingBankAccount.Id); + + bankAccountInDatabase.Cards.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_remove_from_ToMany_relationship() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + existingBankAccount.Cards = _fakers.DebitCard.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(existingBankAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "debitCards", + id = existingBankAccount.Cards[0].StringId + } + } + }; + + var route = $"/bankAccounts/{existingBankAccount.StringId}/relationships/cards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var bankAccountInDatabase = await dbContext.BankAccounts + .Include(bankAccount => bankAccount.Cards) + .FirstAsync(bankAccount => bankAccount.Id == existingBankAccount.Id); + + bankAccountInDatabase.Cards.Should().HaveCount(1); + }); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + existingBankAccount.Cards = _fakers.DebitCard.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(existingBankAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = "/bankAccounts/" + existingBankAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var bankAccountInDatabase = await dbContext.BankAccounts + .Include(bankAccount => bankAccount.Cards) + .FirstOrDefaultAsync(bankAccount => bankAccount.Id == existingBankAccount.Id); + + bankAccountInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_missing_resource() + { + // Arrange + var stringId = HexadecimalCodec.Encode(99999999); + + var route = "/bankAccounts/" + stringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'bankAccounts' with ID '{stringId}' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs new file mode 100644 index 0000000000..ffe9baa52a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public abstract class ObfuscatedIdentifiable : Identifiable + { + protected override string GetStringId(int value) + { + return HexadecimalCodec.Encode(value); + } + + protected override int GetTypedId(string value) + { + return HexadecimalCodec.Decode(value); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs new file mode 100644 index 0000000000..5ce3179878 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public abstract class ObfuscatedIdentifiableController : BaseJsonApiController + where TResource : class, IIdentifiable + { + protected ObfuscatedIdentifiableController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + [HttpGet] + public override Task GetAsync() + { + return base.GetAsync(); + } + + [HttpGet("{id}")] + public Task GetAsync(string id) + { + int idValue = HexadecimalCodec.Decode(id); + return base.GetAsync(idValue); + } + + [HttpGet("{id}/{relationshipName}")] + public Task GetSecondaryAsync(string id, string relationshipName) + { + int idValue = HexadecimalCodec.Decode(id); + return base.GetSecondaryAsync(idValue, relationshipName); + } + + [HttpGet("{id}/relationships/{relationshipName}")] + public Task GetRelationshipAsync(string id, string relationshipName) + { + int idValue = HexadecimalCodec.Decode(id); + return base.GetRelationshipAsync(idValue, relationshipName); + } + + [HttpPost] + public override Task PostAsync([FromBody] TResource resource) + { + return base.PostAsync(resource); + } + + [HttpPost("{id}/relationships/{relationshipName}")] + public Task PostRelationshipAsync(string id, string relationshipName, + [FromBody] ISet secondaryResourceIds) + { + int idValue = HexadecimalCodec.Decode(id); + return base.PostRelationshipAsync(idValue, relationshipName, secondaryResourceIds); + } + + [HttpPatch("{id}")] + public Task PatchAsync(string id, [FromBody] TResource resource) + { + int idValue = HexadecimalCodec.Decode(id); + return base.PatchAsync(idValue, resource); + } + + [HttpPatch("{id}/relationships/{relationshipName}")] + public Task PatchRelationshipAsync(string id, string relationshipName, + [FromBody] object secondaryResourceIds) + { + int idValue = HexadecimalCodec.Decode(id); + return base.PatchRelationshipAsync(idValue, relationshipName, secondaryResourceIds); + } + + [HttpDelete("{id}")] + public Task DeleteAsync(string id) + { + int idValue = HexadecimalCodec.Decode(id); + return base.DeleteAsync(idValue); + } + + [HttpDelete("{id}/relationships/{relationshipName}")] + public Task DeleteRelationshipAsync(string id, string relationshipName, + [FromBody] ISet secondaryResourceIds) + { + int idValue = HexadecimalCodec.Decode(id); + return base.DeleteRelationshipAsync(idValue, relationshipName, secondaryResourceIds); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs new file mode 100644 index 0000000000..489ba4970c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class ObfuscationDbContext : DbContext + { + public DbSet BankAccounts { get; set; } + public DbSet DebitCards { get; set; } + + public ObfuscationDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs new file mode 100644 index 0000000000..4d31f063da --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs @@ -0,0 +1,22 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + internal sealed class ObfuscationFakers : FakerContainer + { + private readonly Lazy> _lazyBankAccountFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(bankAccount => bankAccount.Iban, f => f.Finance.Iban())); + + private readonly Lazy> _lazyDebitCardFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(debitCard => debitCard.OwnerName, f => f.Name.FullName()) + .RuleFor(debitCard => debitCard.PinCode, f => (short)f.Random.Number(1000, 9999))); + + public Faker BankAccount => _lazyBankAccountFaker.Value; + public Faker DebitCard => _lazyDebitCardFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs index a5be6362ae..5d1e8f1842 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs @@ -1,13 +1,9 @@ using System; -using System.Diagnostics; -using System.Linq; -using System.Reflection; using Bogus; -using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing { - internal class WriteFakers + internal sealed class WriteFakers : FakerContainer { private readonly Lazy> _lazyWorkItemFaker = new Lazy>(() => new Faker() @@ -45,67 +41,5 @@ internal class WriteFakers public Faker UserAccount => _lazyUserAccountFaker.Value; public Faker WorkItemGroup => _lazyWorkItemGroupFaker.Value; public Faker RgbColor => _lazyRgbColorFaker.Value; - - private static int GetFakerSeed() - { - // The goal here is to have stable data over multiple test runs, but at the same time different data per test case. - - MethodBase testMethod = GetTestMethod(); - var testName = testMethod.DeclaringType?.FullName + "." + testMethod.Name; - - return GetDeterministicHashCode(testName); - } - - private static MethodBase GetTestMethod() - { - var stackTrace = new StackTrace(); - - var testMethod = stackTrace.GetFrames() - .Select(stackFrame => stackFrame?.GetMethod()) - .FirstOrDefault(IsTestMethod); - - if (testMethod == null) - { - // If called after the first await statement, the test method is no longer on the stack, - // but has been replaced with the compiler-generated async/wait state machine. - throw new InvalidOperationException("Fakers can only be used from within (the start of) a test method."); - } - - return testMethod; - } - - private static bool IsTestMethod(MethodBase method) - { - if (method == null) - { - return false; - } - - return method.GetCustomAttribute(typeof(FactAttribute)) != null || method.GetCustomAttribute(typeof(TheoryAttribute)) != null; - } - - private static int GetDeterministicHashCode(string source) - { - // https://andrewlock.net/why-is-string-gethashcode-different-each-time-i-run-my-program-in-net-core/ - unchecked - { - int hash1 = (5381 << 16) + 5381; - int hash2 = hash1; - - for (int i = 0; i < source.Length; i += 2) - { - hash1 = ((hash1 << 5) + hash1) ^ source[i]; - - if (i == source.Length - 1) - { - break; - } - - hash2 = ((hash2 << 5) + hash2) ^ source[i + 1]; - } - - return hash1 + hash2 * 1566083941; - } - } } } From 6da677ed82d7d09ce07531600d052a6a99575d9d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 19:13:19 +0100 Subject: [PATCH 20/24] Added marker to our TODOs --- .../Repositories/EntityFrameworkCoreRepository.cs | 12 ++++++------ .../Serialization/BaseDeserializer.cs | 2 +- .../Services/JsonApiResourceService.cs | 2 +- .../Acceptance/ManyToManyTests.cs | 2 +- .../Acceptance/Spec/FetchingRelationshipsTests.cs | 2 +- .../Acceptance/Spec/UpdatingRelationshipsTests.cs | 2 +- .../IntegrationTests/Writing/RgbColor.cs | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 81e3c8ae61..4521a89a3c 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -16,7 +16,7 @@ using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; -// TODO: Tests that cover relationship updates with required relationships. All relationships right now are currently optional. +// TODO: @ThisPR Tests that cover relationship updates with required relationships. All relationships right now are currently optional. // - Setting a required relationship to null // - Creating resource with resource // - One-to-one required / optional => what is the current behavior? @@ -189,7 +189,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet @@ -298,7 +298,7 @@ public virtual async Task DeleteAsync(TId id) _dbContext.Remove(resource); await SaveChangesAsync(); - // TODO: Do we need to flush cache here? + // TODO: @ThisPR Do we need to flush cache here? } private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) @@ -352,7 +352,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryRes await UpdateRelationshipAsync(relationship, primaryResource, rightResources); await SaveChangesAsync(); - // TODO: Do we need to flush cache here? + // TODO: @ThisPR Do we need to flush cache here? } /// @@ -483,7 +483,7 @@ private static object GetNonNullValueForProperty(PropertyInfo propertyInfo) if (Nullable.GetUnderlyingType(propertyType) != null) { - // TODO: Write test with primary key property type int? or equivalent. + // TODO: @ThisPR Write test with primary key property type int? or equivalent. propertyType = propertyInfo.PropertyType.GetGenericArguments()[0]; } diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index c9b306e6f0..2454287034 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -120,7 +120,7 @@ protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictio continue; } - // TODO: Extra validation to make sure there are no list-like data for HasOne relationships and vice versa (Write test) + // TODO: @ThisPR Extra validation to make sure there are no list-like data for HasOne relationships and vice versa (Write test) if (attr is HasOneAttribute hasOneAttribute) { SetHasOneRelationship(resource, hasOneAttribute, relationshipData); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 96ca86976f..6ac0f62db3 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -17,7 +17,7 @@ namespace JsonApiDotNetCore.Services { - // TODO: Add write operations to our documentation. + // TODO: @ThisPR Add write operations to our documentation. /// public class JsonApiResourceService : diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 27d4affdb3..79f822e101 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance { - // TODO: Move left-over tests in this file. + // TODO: @ThisPR Move left-over tests in this file. public sealed class ManyToManyTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index dbb1b5a29f..2f3afe0ca6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -60,7 +60,7 @@ public async Task When_getting_existing_ToOne_relationship_it_should_succeed() var json = JsonConvert.DeserializeObject(body).ToString(); - // TODO: links/related was removed from the expected response body here, which violates the json:api spec. + // TODO: @ThisPR links/related was removed from the expected response body here, which violates the json:api spec. string expected = @"{ ""links"": { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 34dbf2c814..ff5d22141f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { - // TODO: Move left-over tests in this file. + // TODO: @ThisPR Move left-over tests in this file. public sealed class UpdatingRelationshipsTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs index 0b260fb0df..7073938b13 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs @@ -8,7 +8,7 @@ public sealed class RgbColor : Identifiable [Attr] public string DisplayName { get; set; } - // TODO: Change into required relationship and add a test that fails when trying to assign null. + // TODO: @ThisPR Change into required relationship and add a test that fails when trying to assign null. [HasOne] public WorkItemGroup Group { get; set; } } From ac6611d00b52142da3ecb695c5df77234d239fdc Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 6 Nov 2020 22:27:00 +0100 Subject: [PATCH 21/24] Added documentation --- docs/internals/index.md | 2 +- docs/request-examples/index.md | 8 +- docs/usage/{ => reading}/filtering.md | 0 .../{ => reading}/including-relationships.md | 0 docs/usage/{ => reading}/pagination.md | 0 docs/usage/{ => reading}/sorting.md | 0 .../sparse-fieldset-selection.md | 0 docs/usage/resources/relationships.md | 3 - docs/usage/toc.md | 17 ++- docs/usage/writing/creating.md | 74 ++++++++++ docs/usage/writing/deleting.md | 9 ++ docs/usage/writing/updating.md | 137 ++++++++++++++++++ .../Services/JsonApiResourceService.cs | 2 - 13 files changed, 237 insertions(+), 15 deletions(-) rename docs/usage/{ => reading}/filtering.md (100%) rename docs/usage/{ => reading}/including-relationships.md (100%) rename docs/usage/{ => reading}/pagination.md (100%) rename docs/usage/{ => reading}/sorting.md (100%) rename docs/usage/{ => reading}/sparse-fieldset-selection.md (100%) create mode 100644 docs/usage/writing/creating.md create mode 100644 docs/usage/writing/deleting.md create mode 100644 docs/usage/writing/updating.md diff --git a/docs/internals/index.md b/docs/internals/index.md index 7e27842923..32dde9619d 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -1,3 +1,3 @@ # Internals -The section contains overviews for the inner workings of the JsonApiDotNetCore library. +This section contains overviews for the inner workings of the JsonApiDotNetCore library. diff --git a/docs/request-examples/index.md b/docs/request-examples/index.md index 58cba05f1c..4d82e95854 100644 --- a/docs/request-examples/index.md +++ b/docs/request-examples/index.md @@ -45,22 +45,22 @@ _Note that cURL requires "[" and "]" in URLs to be escaped._ # Writing data -### Create +### Create resource [!code-ps[REQUEST](010_CREATE_Person.ps1)] [!code-json[RESPONSE](010_CREATE_Person_Response.json)] -### Create with relationship +### Create resource with relationship [!code-ps[REQUEST](011_CREATE_Book-with-Author.ps1)] [!code-json[RESPONSE](011_CREATE_Book-with-Author_Response.json)] -### Update +### Update resource [!code-ps[REQUEST](012_PATCH_Book.ps1)] [!code-json[RESPONSE](012_PATCH_Book_Response.json)] -### Delete +### Delete resource [!code-ps[REQUEST](013_DELETE_Book.ps1)] [!code-json[RESPONSE](013_DELETE_Book_Response.json)] diff --git a/docs/usage/filtering.md b/docs/usage/reading/filtering.md similarity index 100% rename from docs/usage/filtering.md rename to docs/usage/reading/filtering.md diff --git a/docs/usage/including-relationships.md b/docs/usage/reading/including-relationships.md similarity index 100% rename from docs/usage/including-relationships.md rename to docs/usage/reading/including-relationships.md diff --git a/docs/usage/pagination.md b/docs/usage/reading/pagination.md similarity index 100% rename from docs/usage/pagination.md rename to docs/usage/reading/pagination.md diff --git a/docs/usage/sorting.md b/docs/usage/reading/sorting.md similarity index 100% rename from docs/usage/sorting.md rename to docs/usage/reading/sorting.md diff --git a/docs/usage/sparse-fieldset-selection.md b/docs/usage/reading/sparse-fieldset-selection.md similarity index 100% rename from docs/usage/sparse-fieldset-selection.md rename to docs/usage/reading/sparse-fieldset-selection.md diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index b501c6e984..3976e93ebb 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -20,9 +20,6 @@ public class TodoItem : Identifiable } ``` -The convention used to locate the foreign key property (e.g. `OwnerId`) can be changed on -the @JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_RelatedIdMapper - ## HasMany ```c# diff --git a/docs/usage/toc.md b/docs/usage/toc.md index ff9476d800..0dc75882c4 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -3,13 +3,20 @@ ## [Relationships](resources/relationships.md) ## [Resource Definitions](resources/resource-definitions.md) +# Reading data +## [Filtering](reading/filtering.md) +## [Sorting](reading/sorting.md) +## [Pagination](reading/pagination.md) +## [Sparse Fieldset Selection](reading/sparse-fieldset-selection.md) +## [Including Relationships](reading/including-relationships.md) + +# Writing data +## [Creating](writing/creating.md) +## [Updating](writing/updating.md) +## [Deleting](writing/deleting.md) + # [Resource Graph](resource-graph.md) # [Options](options.md) -# [Filtering](filtering.md) -# [Sorting](sorting.md) -# [Pagination](pagination.md) -# [Sparse Fieldset Selection](sparse-fieldset-selection.md) -# [Including Relationships](including-relationships.md) # [Routing](routing.md) # [Errors](errors.md) # [Metadata](meta.md) diff --git a/docs/usage/writing/creating.md b/docs/usage/writing/creating.md new file mode 100644 index 0000000000..7cda3dd61e --- /dev/null +++ b/docs/usage/writing/creating.md @@ -0,0 +1,74 @@ +# Creating resources + +A single resource can be created by sending a POST request. The next example creates a new article: + +```http +POST /articles HTTP/1.1 + +{ + "data": { + "type": "articles", + "attributes": { + "caption": "A new article!", + "url": "www.website.com" + } + } +} +``` + +When using client-generated IDs and only attributes from the request have changed, the server returns `204 No Content`. +Otherwise, the server returns `200 OK`, along with the updated resource and its newly assigned ID. + +In both cases, a `Location` header is returned that contains the URL to the new resource. + +# Creating resources with relationships + +It is possible to create a new resource and establish relationships to existing resources in a single request. +The example below creates an article and sets both its owner and tags. + +```http +POST /articles HTTP/1.1 + +{ + "data": { + "type": "articles", + "attributes": { + "caption": "A new article!" + }, + "relationships": { + "author": { + "data": { + "type": "person", + "id": "101" + } + }, + "tags": { + "data": [ + { + "type": "tag", + "id": "123" + }, + { + "type": "tag", + "id": "456" + } + ] + } + } + } +} +``` + +# Response body + +POST requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields`. For example: + +```http +POST /articles?include=owner&fields[owner]=firstName HTTP/1.1 + +{ + ... +} +``` + +After the resource has been created on the server, it is re-fetched from the database using the specified query string parameters and returned to the client. diff --git a/docs/usage/writing/deleting.md b/docs/usage/writing/deleting.md new file mode 100644 index 0000000000..15c05b622a --- /dev/null +++ b/docs/usage/writing/deleting.md @@ -0,0 +1,9 @@ +# Deleting resources + +A single resource can be deleted using a DELETE request. The next example deletes an article: + +```http +DELETE /articles/1 HTTP/1.1 +``` + +This returns `204 No Content` if the resource was successfully deleted. Alternatively, if the resource does not exist, `404 Not Found` is returned. diff --git a/docs/usage/writing/updating.md b/docs/usage/writing/updating.md new file mode 100644 index 0000000000..447a29550a --- /dev/null +++ b/docs/usage/writing/updating.md @@ -0,0 +1,137 @@ +# Updating resources + +## Updating resource attributes + +To modify the attributes of a single resource, send a PATCH request. The next example changes the article caption: + +```http +POST /articles HTTP/1.1 + +{ + "data": { + "type": "articles", + "id": "1", + "attributes": { + "caption": "This has changed" + } + } +} +``` + +This preserves the values of all other unsent attributes and is called a *partial patch*. + +When only the attributes that were sent in the request have changed, the server returns `204 No Content`. +But if additional attributes have changed (for example, by a database trigger that refreshes the last-modified date) the server returns `200 OK`, along with all attributes of the updated resource. + +## Updating resource relationships + +Besides its attributes, the relationships of a resource can be changed using a PATCH request too. +Note that all resources being assigned in a relationship must already exist. + +When updating a HasMany relationship, the existing set is replaced by the new set. See below on how to add/remove resources. + +The next example replaces both the owner and tags of an article. + +```http +PATCH /articles/1 HTTP/1.1 + +{ + "data": { + "type": "articles", + "id": "1", + "relationships": { + "author": { + "data": { + "type": "person", + "id": "101" + } + }, + "tags": { + "data": [ + { + "type": "tag", + "id": "123" + }, + { + "type": "tag", + "id": "456" + } + ] + } + } + } +} +``` + +A HasOne relationship can be cleared by setting `data` to `null`, while a HasMany relationship can be cleared by setting it to an empty array. + +By combining the examples above, both attributes and relationships can be updated using a single PATCH request. + +## Response body + +PATCH requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields`. For example: + +```http +PATCH /articles/1?include=owner&fields[owner]=firstName HTTP/1.1 + +{ + ... +} +``` + +After the resource has been updated on the server, it is re-fetched from the database using the specified query string parameters and returned to the client. +Note this only has an effect when `200 OK` is returned. + +# Updating relationships + +Although relationships can be modified along with resources (as described above), it is also possible to change a single relationship using a relationship URL. +The same rules for clearing the relationship apply. And similar to PATCH on a resource URL, updating a HasMany relationship replaces the existing set. + +The next example changes just the owner of an article, by sending a PATCH request to its relationship URL. + +```http +PATCH /articles/1/relationships/owner HTTP/1.1 + +{ + "data": { + "type": "person", + "id": "101" + } +} +``` + +The server returns `204 No Content` when the update is successful. + +## Changing HasMany relationships + +The POST and DELETE verbs can be used on HasMany relationship URLs to add or remove resources to/from an existing set without replacing it. + +The next example adds another tag to the existing set of tags of an article: + +```http +POST /articles/1/relationships/tags HTTP/1.1 + +{ + "data": [ + { + "type": "tag", + "id": "789" + } + ] +} +``` + +Likewise, the next example removes a single tag from the set of tags of an article: + +```http +DELETE /articles/1/relationships/tags HTTP/1.1 + +{ + "data": [ + { + "type": "tag", + "id": "789" + } + ] +} +``` diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 6ac0f62db3..1f62541909 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -17,8 +17,6 @@ namespace JsonApiDotNetCore.Services { - // TODO: @ThisPR Add write operations to our documentation. - /// public class JsonApiResourceService : IResourceService From beaec67490691c53051ab5cd5593834bdbb8abd6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 10 Nov 2020 11:02:12 +0100 Subject: [PATCH 22/24] Tests for cyclic relationships --- .../Configuration/ResourceGraphBuilder.cs | 32 ++- .../Annotations/HasManyThroughAttribute.cs | 12 + .../Spec/UpdatingRelationshipsTests.cs | 214 ----------------- .../AddToToManyRelationshipTests.cs | 103 ++++++++ .../RemoveFromToManyRelationshipTests.cs | 111 ++++++++- .../ReplaceToManyRelationshipTests.cs | 183 ++++++++++++++ .../UpdateToOneRelationshipTests.cs | 82 +++++++ .../ReplaceToManyRelationshipTests.cs | 227 ++++++++++++++++++ .../Updating/Resources/UpdateResourceTests.cs | 93 +++++++ .../Resources/UpdateToOneRelationshipTests.cs | 104 ++++++++ .../IntegrationTests/Writing/WorkItem.cs | 16 ++ .../Writing/WorkItemToWorkItem.cs | 11 + .../Writing/WriteDbContext.cs | 13 + 13 files changed, 980 insertions(+), 221 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemToWorkItem.cs diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index be0eb85682..e679e464a9 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -187,22 +187,42 @@ private IReadOnlyCollection GetRelationships(Type resourc var throughProperties = throughType.GetProperties(); // ArticleTag.Article - hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType.IsAssignableFrom(resourceType)) - ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {resourceType}"); + if (hasManyThroughAttribute.LeftPropertyName != null) + { + // In case of a self-referencing many-to-many relationship, the left property name must be specified. + hasManyThroughAttribute.LeftProperty = hasManyThroughAttribute.ThroughType.GetProperty(hasManyThroughAttribute.LeftPropertyName) + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property named '{hasManyThroughAttribute.LeftPropertyName}'."); + } + else + { + // In case of a non-self-referencing many-to-many relationship, we just pick the single compatible type. + hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType.IsAssignableFrom(resourceType)) + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property to type '{resourceType}'."); + } // ArticleTag.ArticleId var leftIdPropertyName = hasManyThroughAttribute.LeftIdPropertyName ?? hasManyThroughAttribute.LeftProperty.Name + "Id"; hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(x => x.Name == leftIdPropertyName) - ?? throw new InvalidConfigurationException($"{throughType} does not contain a relationship ID property to type {resourceType} with name {leftIdPropertyName}"); + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a relationship ID property to type '{resourceType}' with name '{leftIdPropertyName}'."); // ArticleTag.Tag - hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.RightType) - ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}"); + if (hasManyThroughAttribute.RightPropertyName != null) + { + // In case of a self-referencing many-to-many relationship, the right property name must be specified. + hasManyThroughAttribute.RightProperty = hasManyThroughAttribute.ThroughType.GetProperty(hasManyThroughAttribute.RightPropertyName) + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property named '{hasManyThroughAttribute.RightPropertyName}'."); + } + else + { + // In case of a non-self-referencing many-to-many relationship, we just pick the single compatible type. + hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.RightType) + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property to type '{hasManyThroughAttribute.RightType}'."); + } // ArticleTag.TagId var rightIdPropertyName = hasManyThroughAttribute.RightIdPropertyName ?? hasManyThroughAttribute.RightProperty.Name + "Id"; hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(x => x.Name == rightIdPropertyName) - ?? throw new InvalidConfigurationException($"{throughType} does not contain a relationship ID property to type {hasManyThroughAttribute.RightType} with name {rightIdPropertyName}"); + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a relationship ID property to type '{hasManyThroughAttribute.RightType}' with name '{rightIdPropertyName}'."); } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 66584d290d..76cde56b59 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -91,6 +91,18 @@ public sealed class HasManyThroughAttribute : HasManyAttribute /// public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; + /// + /// Required for a self-referencing many-to-many relationship. + /// Contains the name of the property back to the parent resource from the through type. + /// + public string LeftPropertyName { get; set; } + + /// + /// Required for a self-referencing many-to-many relationship. + /// Contains the name of the property to the related resource from the through type. + /// + public string RightPropertyName { get; set; } + /// /// Optional. Can be used to indicate a non-default name for the ID property back to the parent resource from the through type. /// Defaults to the name of suffixed with "Id". diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs deleted file mode 100644 index ff5d22141f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.EntityFrameworkCore; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - // TODO: @ThisPR Move left-over tests in this file. - - public sealed class UpdatingRelationshipsTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - - private readonly Faker _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - private readonly Faker _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - - public UpdatingRelationshipsTests(IntegrationTestContext testContext) - { - _testContext = testContext; - } - - [Fact] - public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var otherTodoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.AddRange(todoItem, otherTodoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - relationships = new Dictionary - { - ["childrenTodos"] = new - { - data = new[] - { - new - { - type = "todoItems", - id = todoItem.StringId - }, - new - { - type = "todoItems", - id = otherTodoItem.StringId - } - } - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoItemInDatabase = await dbContext.TodoItems - .Include(item => item.ChildrenTodos) - .FirstAsync(item => item.Id == todoItem.Id); - - todoItemInDatabase.ChildrenTodos.Should().HaveCount(2); - todoItemInDatabase.ChildrenTodos.Should().ContainSingle(x => x.Id == todoItem.Id); - todoItemInDatabase.ChildrenTodos.Should().ContainSingle(x => x.Id == otherTodoItem.Id); - }); - } - - [Fact] - public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - relationships = new Dictionary - { - ["dependentOnTodo"] = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId - } - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoItemInDatabase = await dbContext.TodoItems - .Include(item => item.DependentOnTodo) - .FirstAsync(item => item.Id == todoItem.Id); - - todoItemInDatabase.DependentOnTodo.Id.Should().Be(todoItem.Id); - }); - } - - [Fact] - public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patching_Resource() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - var otherTodoItem = _todoItemFaker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.AddRange(todoItem, otherTodoItem); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId, - relationships = new Dictionary - { - ["dependentOnTodo"] = new - { - data = new - { - type = "todoItems", - id = todoItem.StringId - } - }, - ["childrenTodos"] = new - { - data = new[] - { - new - { - type = "todoItems", - id = todoItem.StringId - }, - new - { - type = "todoItems", - id = otherTodoItem.StringId - } - } - } - } - } - }; - - var route = "/api/v1/todoItems/" + todoItem.StringId; - - // Act - var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var todoItemInDatabase = await dbContext.TodoItems - .Include(item => item.ParentTodo) - .FirstAsync(item => item.Id == todoItem.Id); - - todoItemInDatabase.ParentTodo.Id.Should().Be(todoItem.Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs index 53cea64a81..83b2dca573 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -660,5 +661,107 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Subscribers.Should().HaveCount(0); }); } + + [Fact] + public async Task Can_add_self_to_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Children = _fakers.WorkItem.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/children"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(2); + workItemInDatabase.Children.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.Children[0].Id); + workItemInDatabase.Children.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.Id); + }); + } + + [Fact] + public async Task Can_add_self_to_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.RelatedToItems = new List + { + new WorkItemToWorkItem + { + ToItem = _fakers.WorkItem.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/relatedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedToItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedToItems.Should().HaveCount(2); + workItemInDatabase.RelatedToItems.Should().OnlyContain(workItemToWorkItem => workItemToWorkItem.FromItem.Id == existingWorkItem.Id); + workItemInDatabase.RelatedToItems.Should().ContainSingle(workItemToWorkItem => workItemToWorkItem.ToItem.Id == existingWorkItem.Id); + workItemInDatabase.RelatedToItems.Should().ContainSingle(workItemToWorkItem => workItemToWorkItem.ToItem.Id == existingWorkItem.RelatedToItems[0].ToItem.Id); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index b8b7dbcaae..a52f9e6525 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -640,7 +641,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); @@ -657,5 +658,113 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); }); } + + [Fact] + public async Task Can_remove_self_from_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Children = _fakers.WorkItem.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Children.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/children"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Children[0].Id); + }); + } + + [Fact] + public async Task Can_remove_self_from_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.RelatedFromItems = new List + { + new WorkItemToWorkItem + { + FromItem = _fakers.WorkItem.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.RelatedFromItems.Add(new WorkItemToWorkItem + { + FromItem = existingWorkItem + }); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/relatedFrom"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedFromItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.FromItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedFromItems.Should().HaveCount(1); + workItemInDatabase.RelatedFromItems[0].FromItem.Id.Should().Be(existingWorkItem.RelatedFromItems[0].FromItem.Id); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs index b8e234ab6a..5e3f20e286 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -722,5 +723,187 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] for to-many relationship."); responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] for 'tags' relationship. - Request body: <<"); } + + [Fact] + public async Task Can_clear_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Children = new List + { + existingWorkItem + }; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/children"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_clear_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.RelatedFromItems = new List + { + new WorkItemToWorkItem + { + FromItem = existingWorkItem + } + }; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/relatedFrom"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedFromItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.FromItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedFromItems.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_assign_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/children"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Id); + }); + } + + [Fact] + public async Task Can_assign_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/relatedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedToItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedToItems.Should().HaveCount(1); + workItemInDatabase.RelatedToItems[0].FromItem.Id.Should().Be(existingWorkItem.Id); + workItemInDatabase.RelatedToItems[0].ToItem.Id.Should().Be(existingWorkItem.Id); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs index fbe765e1cb..416a68b678 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -548,5 +548,87 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Resource type mismatch between request body and endpoint URL."); responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'userAccounts' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}/relationships/assignee', instead of 'rgbColors'."); } + + [Fact] + public async Task Can_clear_cyclic_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Parent = existingWorkItem; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/parent"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_assign_cyclic_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/parent"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs index f2efb8504b..d9e9a9b8bb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -834,5 +835,231 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); } + + [Fact] + public async Task Can_clear_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Children = new List + { + existingWorkItem + }; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + children = new + { + data = new object[0] + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_clear_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.RelatedFromItems = new List + { + new WorkItemToWorkItem + { + FromItem = existingWorkItem + } + }; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + relatedFrom = new + { + data = new object[0] + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedFromItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.FromItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedFromItems.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_assign_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + children = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Id); + }); + } + + [Fact] + public async Task Can_assign_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + relatedTo = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedToItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedToItems.Should().HaveCount(1); + workItemInDatabase.RelatedToItems[0].FromItem.Id.Should().Be(existingWorkItem.Id); + workItemInDatabase.RelatedToItems[0].ToItem.Id.Should().Be(existingWorkItem.Id); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs index c2f4d09562..43e428e17f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs @@ -1075,5 +1075,98 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); }); } + + [Fact] + public async Task Can_update_resource_with_multiple_cyclic_relationship_types() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Parent = _fakers.WorkItem.Generate(); + existingWorkItem.Children = _fakers.WorkItem.Generate(1); + existingWorkItem.RelatedToItems = new List + { + new WorkItemToWorkItem + { + ToItem = _fakers.WorkItem.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + parent = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }, + children = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }, + relatedTo = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .Include(workItem => workItem.Children) + .Include(workItem => workItem.RelatedToItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children.Single().Id.Should().Be(existingWorkItem.Id); + + workItemInDatabase.RelatedToItems.Should().HaveCount(1); + workItemInDatabase.RelatedToItems.Single().ToItem.Id.Should().Be(existingWorkItem.Id); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs index c00a15b211..18f9891812 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -657,5 +657,109 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); responseDocument.Errors[0].Detail.Should().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); } + + [Fact] + public async Task Can_clear_cyclic_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Parent = existingWorkItem; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + parent = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_assign_cyclic_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + parent = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs index aacfd8613a..0c8a4c518f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs @@ -32,6 +32,22 @@ public sealed class WorkItem : Identifiable public ISet Tags { get; set; } public ICollection WorkItemTags { get; set; } + [HasOne] + public WorkItem Parent { get; set; } + + [HasMany] + public IList Children { get; set; } + + [NotMapped] + [HasManyThrough(nameof(RelatedFromItems), LeftPropertyName = nameof(WorkItemToWorkItem.ToItem), RightPropertyName = nameof(WorkItemToWorkItem.FromItem))] + public IList RelatedFrom { get; set; } + public IList RelatedFromItems { get; set; } + + [NotMapped] + [HasManyThrough(nameof(RelatedToItems), LeftPropertyName = nameof(WorkItemToWorkItem.FromItem), RightPropertyName = nameof(WorkItemToWorkItem.ToItem))] + public IList RelatedTo { get; set; } + public IList RelatedToItems { get; set; } + [HasOne] public WorkItemGroup Group { get; set; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemToWorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemToWorkItem.cs new file mode 100644 index 0000000000..63c43a6865 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemToWorkItem.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +{ + public sealed class WorkItemToWorkItem + { + public WorkItem FromItem { get; set; } + public int FromItemId { get; set; } + + public WorkItem ToItem { get; set; } + public int ToItemId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs index ab63f54368..4a7d2a111c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs @@ -33,6 +33,19 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasKey(workItemTag => new { workItemTag.ItemId, workItemTag.TagId}); + + builder.Entity() + .HasKey(item => new { item.FromItemId, item.ToItemId}); + + builder.Entity() + .HasOne(workItemToWorkItem => workItemToWorkItem.FromItem) + .WithMany(workItem => workItem.RelatedToItems) + .HasForeignKey(workItemToWorkItem => workItemToWorkItem.FromItemId); + + builder.Entity() + .HasOne(workItemToWorkItem => workItemToWorkItem.ToItem) + .WithMany(workItem => workItem.RelatedFromItems) + .HasForeignKey(workItemToWorkItem => workItemToWorkItem.ToItemId); } } } From 5929957c7c8ae858153e5de3c96708f22eea6d05 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 10 Nov 2020 11:17:13 +0100 Subject: [PATCH 23/24] Folder rename --- .../{Writing => ReadWrite}/Creating/CreateResourceTests.cs | 2 +- .../Creating/CreateResourceWithClientGeneratedIdTests.cs | 2 +- .../Creating/CreateResourceWithToManyRelationshipTests.cs | 2 +- .../Creating/CreateResourceWithToOneRelationshipTests.cs | 2 +- .../{Writing => ReadWrite}/Deleting/DeleteResourceTests.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/RgbColor.cs | 2 +- .../{Writing => ReadWrite}/RgbColorsController.cs | 2 +- .../Updating/Relationships/AddToToManyRelationshipTests.cs | 2 +- .../Updating/Relationships/RemoveFromToManyRelationshipTests.cs | 2 +- .../Updating/Relationships/ReplaceToManyRelationshipTests.cs | 2 +- .../Updating/Relationships/UpdateToOneRelationshipTests.cs | 2 +- .../Updating/Resources/ReplaceToManyRelationshipTests.cs | 2 +- .../Updating/Resources/UpdateResourceTests.cs | 2 +- .../Updating/Resources/UpdateToOneRelationshipTests.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/UserAccount.cs | 2 +- .../{Writing => ReadWrite}/UserAccountsController.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/WorkItem.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/WorkItemGroup.cs | 2 +- .../{Writing => ReadWrite}/WorkItemGroupsController.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/WorkItemPriority.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/WorkItemTag.cs | 2 +- .../{Writing => ReadWrite}/WorkItemToWorkItem.cs | 2 +- .../{Writing => ReadWrite}/WorkItemsController.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/WorkTag.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/WriteDbContext.cs | 2 +- .../IntegrationTests/{Writing => ReadWrite}/WriteFakers.cs | 2 +- 26 files changed, 26 insertions(+), 26 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Creating/CreateResourceTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Creating/CreateResourceWithClientGeneratedIdTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Creating/CreateResourceWithToManyRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Creating/CreateResourceWithToOneRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Deleting/DeleteResourceTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/RgbColor.cs (85%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/RgbColorsController.cs (87%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Updating/Relationships/AddToToManyRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Updating/Relationships/RemoveFromToManyRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Updating/Relationships/ReplaceToManyRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Updating/Relationships/UpdateToOneRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Updating/Resources/ReplaceToManyRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Updating/Resources/UpdateResourceTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/Updating/Resources/UpdateToOneRelationshipTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/UserAccount.cs (85%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/UserAccountsController.cs (87%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WorkItem.cs (96%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WorkItemGroup.cs (90%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WorkItemGroupsController.cs (88%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WorkItemPriority.cs (57%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WorkItemTag.cs (76%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WorkItemToWorkItem.cs (78%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WorkItemsController.cs (87%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WorkTag.cs (79%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WriteDbContext.cs (96%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{Writing => ReadWrite}/WriteFakers.cs (97%) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 82eaebba6f..568f389851 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating { public sealed class CreateResourceTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 92c19bbdf6..b5ffbec5d7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating { public sealed class CreateResourceWithClientGeneratedIdTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index 1d3b71b177..f994978bba 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating { public sealed class CreateResourceWithToManyRelationshipTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index c552a3b399..ed706e2fcc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -9,7 +9,7 @@ using Newtonsoft.Json; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Creating +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating { public sealed class CreateResourceWithToOneRelationshipTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs index 3e932dfdfd..0d90d5d002 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Deleting +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Deleting { public sealed class DeleteResourceTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColor.cs similarity index 85% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColor.cs index 7073938b13..d44e600f49 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColor.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColor.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class RgbColor : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColorsController.cs similarity index 87% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColorsController.cs index 45113fe3e2..9d8e32bc25 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/RgbColorsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColorsController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class RgbColorsController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs index 83b2dca573..9c9d9068e2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships { public sealed class AddToToManyRelationshipTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index a52f9e6525..5a3e99bfbc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships { public sealed class RemoveFromToManyRelationshipTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 5e3f20e286..ffd4a2ee13 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships { public sealed class ReplaceToManyRelationshipTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs index 416a68b678..5f87c16863 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Relationships +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships { public sealed class UpdateToOneRelationshipTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index d9e9a9b8bb..bf183e57c0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources { public sealed class ReplaceToManyRelationshipTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 43e428e17f..223ee17267 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources { public sealed class UpdateResourceTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index 18f9891812..c3636344fe 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing.Updating.Resources +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources { public sealed class UpdateToOneRelationshipTests : IClassFixture, WriteDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccount.cs similarity index 85% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccount.cs index 1e4b60d612..1ed7034038 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccount.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccount.cs @@ -2,7 +2,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class UserAccount : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccountsController.cs similarity index 87% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccountsController.cs index 0e2ff633b7..9d409bbb57 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/UserAccountsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccountsController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class UserAccountsController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs similarity index 96% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs index 0c8a4c518f..13f09d4725 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class WorkItem : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs similarity index 90% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs index 37dc8cd78c..4eadea345c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class WorkItemGroup : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs similarity index 88% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs index fffef616d5..c6b00f25e3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemGroupsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class WorkItemGroupsController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs similarity index 57% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs index 31d639dbb7..baa810f7c0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemPriority.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public enum WorkItemPriority { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs similarity index 76% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs index 4efad89a0b..d9c13d9e4c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemTag.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class WorkItemTag { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemToWorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs similarity index 78% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemToWorkItem.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs index 63c43a6865..5d2eb588e8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemToWorkItem.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class WorkItemToWorkItem { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs similarity index 87% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs index 2bfc3c3c42..e3e90fe0f3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkItemsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class WorkItemsController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkTag.cs similarity index 79% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkTag.cs index 8fe6a903b3..04ef9d3d95 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WorkTag.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkTag.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class WorkTag : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteDbContext.cs similarity index 96% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteDbContext.cs index 4a7d2a111c..08f66e8d5a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteDbContext.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { public sealed class WriteDbContext : DbContext { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteFakers.cs similarity index 97% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteFakers.cs index 5d1e8f1842..ce2ec19eda 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Writing/WriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteFakers.cs @@ -1,7 +1,7 @@ using System; using Bogus; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Writing +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { internal sealed class WriteFakers : FakerContainer { From 7b8f44b9ae5c94c5d5a4278ebea6876d94151d13 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 10 Nov 2020 20:57:10 +0100 Subject: [PATCH 24/24] Migrated left-over tests --- .../Acceptance/ManyToManyTests.cs | 99 ----- .../DocumentTests/LinksWithNamespaceTests.cs | 25 -- .../Acceptance/Spec/FetchingDataTests.cs | 167 -------- .../Spec/FetchingRelationshipsTests.cs | 372 ------------------ .../Acceptance/TodoItemControllerTests.cs | 366 ----------------- .../ClientGeneratedIdsApplicationFactory.cs | 32 -- .../CompositeKeys/CompositeKeyTests.cs | 1 - .../Links/RelativeLinksWithNamespaceTests.cs | 144 +++++++ .../ObjectAssertionsExtensions.cs | 28 ++ .../PaginationWithTotalCountTests.cs | 45 +++ .../Fetching/FetchRelationshipTests.cs | 246 ++++++++++++ .../ReadWrite/Fetching/FetchResourceTests.cs | 371 +++++++++++++++++ 12 files changed, 834 insertions(+), 1062 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs deleted file mode 100644 index 79f822e101..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Net; -using System.Threading.Tasks; -using Bogus; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - // TODO: @ThisPR Move left-over tests in this file. - - public sealed class ManyToManyTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - - private readonly Faker _authorFaker; - private readonly Faker
_articleFaker; - private readonly Faker _tagFaker; - - public ManyToManyTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - _authorFaker = new Faker() - .RuleFor(a => a.LastName, f => f.Random.Words(2)); - - _articleFaker = new Faker
() - .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) - .RuleFor(a => a.Author, f => _authorFaker.Generate()); - - _tagFaker = new Faker() - .CustomInstantiator(f => new Tag()) - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); - } - - [Fact] - public async Task Can_Get_HasManyThrough_Relationship_Through_Secondary_Endpoint() - { - // Arrange - var existingArticleTag = new ArticleTag - { - Article = _articleFaker.Generate(), - Tag = _tagFaker.Generate() - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.ArticleTags.Add(existingArticleTag); - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Type.Should().Be("tags"); - responseDocument.ManyData[0].Id.Should().Be(existingArticleTag.Tag.StringId); - responseDocument.ManyData[0].Attributes["name"].Should().Be(existingArticleTag.Tag.Name); - } - - [Fact] - public async Task Can_Get_HasManyThrough_Through_Relationship_Endpoint() - { - // Arrange - var existingArticleTag = new ArticleTag - { - Article = _articleFaker.Generate(), - Tag = _tagFaker.Generate() - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.ArticleTags.Add(existingArticleTag); - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/articles/{existingArticleTag.Article.StringId}/relationships/tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Type.Should().Be("tags"); - responseDocument.ManyData[0].Id.Should().Be(existingArticleTag.Tag.StringId); - responseDocument.ManyData[0].Attributes.Should().BeNull(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs index 25bc0d8a80..6b51a357fd 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs @@ -15,31 +15,6 @@ public LinksWithNamespaceTests(StandardApplicationFactory factory) : base(factor { } - [Fact] - public async Task GET_RelativeLinks_True_With_Namespace_Returns_RelativeLinks() - { - // Arrange - var person = new Person(); - - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - - var route = "/api/v1/people/" + person.StringId; - var request = new HttpRequestMessage(HttpMethod.Get, route); - - var options = (JsonApiOptions) _factory.GetRequiredService(); - options.UseRelativeLinks = true; - - // Act - var response = await _factory.Client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("/api/v1/people/" + person.StringId, document.Links.Self); - } - [Fact] public async Task GET_RelativeLinks_False_With_Namespace_Returns_AbsoluteLinks() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs deleted file mode 100644 index d80be3aba0..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class FetchingDataTests - { - private readonly TestFixture _fixture; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public FetchingDataTests(TestFixture fixture) - { - _fixture = fixture; - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - } - - [Fact] - public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() - { - // Arrange - var context = _fixture.GetRequiredService(); - await context.ClearTableAsync(); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var result = _fixture.GetDeserializer().DeserializeMany(body); - var items = result.Data; - var meta = result.Meta; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(HeaderConstants.MediaType, response.Content.Headers.ContentType.ToString()); - Assert.Empty(items); - Assert.Equal(0, int.Parse(meta["totalResources"].ToString())); - context.Dispose(); - } - - [Fact] - public async Task Included_Resources_Contain_Relationship_Links() - { - // Arrange - var context = _fixture.GetRequiredService(); - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - todoItem.Owner = person; - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonConvert.DeserializeObject(body); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(person.StringId, deserializedBody.Included[0].Id); - Assert.NotNull(deserializedBody.Included[0].Relationships); - Assert.Equal($"http://localhost/api/v1/people/{person.Id}/todoItems", deserializedBody.Included[0].Relationships["todoItems"].Links.Related); - Assert.Equal($"http://localhost/api/v1/people/{person.Id}/relationships/todoItems", deserializedBody.Included[0].Relationships["todoItems"].Links.Self); - context.Dispose(); - } - - [Fact] - public async Task GetResources_NoDefaultPageSize_ReturnsResources() - { - // Arrange - var context = _fixture.GetRequiredService(); - await context.ClearTableAsync(); - await context.SaveChangesAsync(); - - var todoItems = _todoItemFaker.Generate(20); - context.TodoItems.AddRange(todoItems); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var server = new TestServer(builder); - - var options = (JsonApiOptions)server.Services.GetRequiredService(); - options.DefaultPageSize = null; - - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var result = _fixture.GetDeserializer().DeserializeMany(body); - - // Assert - Assert.True(result.Data.Count == 20); - } - - [Fact] - public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() - { - // Arrange - var context = _fixture.GetRequiredService(); - await context.ClearTableAsync(); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems/123"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '123' does not exist.", errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs deleted file mode 100644 index 2f3afe0ca6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ /dev/null @@ -1,372 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Extensions; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class FetchingRelationshipsTests - { - private readonly TestFixture _fixture; - private readonly Faker _todoItemFaker; - - public FetchingRelationshipsTests(TestFixture fixture) - { - _fixture = fixture; - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - } - - [Fact] - public async Task When_getting_existing_ToOne_relationship_it_should_succeed() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = new Person(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var json = JsonConvert.DeserializeObject(body).ToString(); - - // TODO: @ThisPR links/related was removed from the expected response body here, which violates the json:api spec. - - string expected = @"{ - ""links"": { - ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"" - }, - ""data"": { - ""type"": ""people"", - ""id"": """ + todoItem.Owner.StringId + @""" - } -}"; - Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); - } - - [Fact] - public async Task When_getting_existing_ToMany_relationship_it_should_succeed() - { - // Arrange - var author = new Author - { - LastName = "X", - Articles = new List
- { - new Article - { - Caption = "Y" - }, - new Article - { - Caption = "Z" - } - } - }; - - var context = _fixture.GetRequiredService(); - context.AuthorDifferentDbContextName.Add(author); - await context.SaveChangesAsync(); - - var route = $"/api/v1/authors/{author.Id}/relationships/articles"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var json = JsonConvert.DeserializeObject(body).ToString(); - - var expected = @"{ - ""links"": { - ""self"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"", - ""first"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"" - }, - ""data"": [ - { - ""type"": ""articles"", - ""id"": """ + author.Articles[0].StringId + @""" - }, - { - ""type"": ""articles"", - ""id"": """ + author.Articles[1].StringId + @""" - } - ] -}"; - - Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); - } - - [Fact] - public async Task When_getting_related_missing_to_one_resource_it_should_succeed_with_null_data() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = null; - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.StringId}/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var json = JsonConvert.DeserializeObject(body).ToString(); - - var expected = @"{ - ""links"": { - ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" - }, - ""data"": null -}"; - - Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); - } - - [Fact] - public async Task When_getting_relationship_for_missing_to_one_resource_it_should_succeed_with_null_data() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = null; - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var doc = JsonConvert.DeserializeObject(body); - Assert.False(doc.IsManyData); - Assert.Null(doc.Data); - } - - [Fact] - public async Task When_getting_related_missing_to_many_resource_it_should_succeed_with_null_data() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.ChildrenTodos = new List(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/childrenTodos"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var doc = JsonConvert.DeserializeObject(body); - Assert.True(doc.IsManyData); - Assert.Empty(doc.ManyData); - } - - [Fact] - public async Task When_getting_relationship_for_missing_to_many_resource_it_should_succeed_with_null_data() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.ChildrenTodos = new List(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/childrenTodos"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var doc = JsonConvert.DeserializeObject(body); - Assert.True(doc.IsManyData); - Assert.Empty(doc.ManyData); - } - - [Fact] - public async Task When_getting_related_for_missing_parent_resource_it_should_fail() - { - // Arrange - var route = "/api/v1/todoItems/99999999/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task When_getting_relationship_for_missing_parent_resource_it_should_fail() - { - // Arrange - var route = "/api/v1/todoItems/99999999/relationships/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task When_getting_unknown_related_resource_it_should_fail() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/invalid"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task When_getting_unknown_relationship_for_resource_it_should_fail() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs deleted file mode 100644 index 8c9db7094d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ /dev/null @@ -1,366 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class TodoItemControllerTests - { - private readonly TestFixture _fixture; - private readonly AppDbContext _context; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public TodoItemControllerTests(TestFixture fixture) - { - _fixture = fixture; - _context = fixture.GetRequiredService(); - - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()) - .RuleFor(t => t.Age, f => f.Random.Int(1, 99)); - } - - [Fact] - public async Task Can_Get_TodoItems_Paginate_Check() - { - // Arrange - var expectedResourcesPerPage = _fixture.GetRequiredService().DefaultPageSize.Value; - - var person = _personFaker.Generate(); - var todoItems = _todoItemFaker.Generate(expectedResourcesPerPage + 1); - - foreach (var todoItem in todoItems) - { - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - } - - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeMany(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - Assert.True(deserializedBody.Count <= expectedResourcesPerPage, $"There are more items on the page than the default page size. {deserializedBody.Count} > {expectedResourcesPerPage}"); - } - - [Fact] - public async Task Can_Get_TodoItem_ById() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(todoItem.Id, deserializedBody.Id); - Assert.Equal(todoItem.Description, deserializedBody.Description); - Assert.Equal(todoItem.Ordinal, deserializedBody.Ordinal); - Assert.Equal(todoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Null(deserializedBody.AchievedDate); - } - - [Fact] - public async Task Can_Post_TodoItem() - { - // Arrange - var serializer = _fixture.GetSerializer(e => new { e.Description, e.OffsetDate, e.Ordinal, e.CreatedDate }, e => new { e.Owner }); - var nowOffset = new DateTimeOffset(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.OffsetDate = nowOffset; - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/todoItems"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(serializer.Serialize(todoItem)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal(todoItem.Description, deserializedBody.Description); - Assert.Equal(todoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Equal(nowOffset, deserializedBody.OffsetDate); - Assert.Null(deserializedBody.AchievedDate); - } - - [Fact] - public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() - { - // Arrange - var person1 = _personFaker.Generate(); - var person2 = _personFaker.Generate(); - - _context.People.AddRange(person1, person2); - await _context.SaveChangesAsync(); - - var todoItem = _todoItemFaker.Generate(); - - var content = new - { - data = new - { - type = "todoItems", - attributes = new Dictionary - { - { "description", todoItem.Description }, - { "ordinal", todoItem.Ordinal }, - { "createdDate", todoItem.CreatedDate } - }, - relationships = new - { - owner = new - { - data = new - { - type = "people", - id = person1.Id.ToString() - } - }, - assignee = new - { - data = new - { - type = "people", - id = person2.Id.ToString() - } - } - } - } - }; - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/todoItems"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert -- response - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - var resultId = int.Parse(document.SingleData.Id); - - // Assert -- database - var todoItemResult = await _context.TodoItems - .Include(t => t.Owner) - .Include(t => t.Assignee) - .SingleAsync(t => t.Id == resultId); - - Assert.Equal(person1.Id, todoItemResult.Owner.Id); - Assert.Equal(person2.Id, todoItemResult.Assignee.Id); - } - - [Fact] - public async Task Can_Patch_TodoItem() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var newTodoItem = _todoItemFaker.Generate(); - - var content = new - { - data = new - { - id = todoItem.Id, - type = "todoItems", - attributes = new Dictionary - { - { "description", newTodoItem.Description }, - { "ordinal", newTodoItem.Ordinal }, - { "alwaysChangingValue", "ignored" }, - { "createdDate", newTodoItem.CreatedDate } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(newTodoItem.Description, deserializedBody.Description); - Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); - Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Null(deserializedBody.AchievedDate); - } - - [Fact] - public async Task Can_Patch_TodoItemWithNullable() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = _personFaker.Generate(); - - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var newTodoItem = _todoItemFaker.Generate(); - newTodoItem.AchievedDate = new DateTime(2002, 2,4); - - var content = new - { - data = new - { - id = todoItem.Id, - type = "todoItems", - attributes = new Dictionary - { - { "description", newTodoItem.Description }, - { "ordinal", newTodoItem.Ordinal }, - { "createdDate", newTodoItem.CreatedDate }, - { "achievedDate", newTodoItem.AchievedDate } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(newTodoItem.Description, deserializedBody.Description); - Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); - Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Equal(newTodoItem.AchievedDate.GetValueOrDefault().ToString("G"), deserializedBody.AchievedDate.GetValueOrDefault().ToString("G")); - } - - [Fact] - public async Task Can_Patch_TodoItemWithNullValue() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = _personFaker.Generate(); - - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var newTodoItem = _todoItemFaker.Generate(); - - var content = new - { - data = new - { - id = todoItem.Id, - type = "todoItems", - attributes = new Dictionary - { - { "description", newTodoItem.Description }, - { "ordinal", newTodoItem.Ordinal }, - { "createdDate", newTodoItem.CreatedDate }, - { "achievedDate", null } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(newTodoItem.Description, deserializedBody.Description); - Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); - Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Null(deserializedBody.AchievedDate); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs deleted file mode 100644 index 570e479b16..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs +++ /dev/null @@ -1,32 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; - -namespace JsonApiDotNetCoreExampleTests -{ - public class ClientGeneratedIdsApplicationFactory : CustomApplicationFactoryBase - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - base.ConfigureWebHost(builder); - - builder.ConfigureServices(services => - { - services.AddClientSerialization(); - }); - - builder.ConfigureTestServices(services => - { - services.AddJsonApi(options => - { - options.Namespace = "api/v1"; - options.DefaultPageSize = new PageSize(5); - options.IncludeTotalResourceCount = true; - options.AllowClientGeneratedIds = true; - options.IncludeExceptionStackTraceInErrors = true; - }, - discovery => discovery.AddAssembly(typeof(JsonApiDotNetCoreExample.Program).Assembly)); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index f4cfd47541..9f20310613 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -525,7 +525,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); responseDocument.Errors[0].Title.Should().Be("A related resource does not exist."); responseDocument.Errors[0].Detail.Should().Be("Related resource of type 'cars' with ID '999:XX-YY-22' in relationship 'inventory' does not exist."); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs new file mode 100644 index 0000000000..93c8630303 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class RelativeLinksWithNamespaceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public RelativeLinksWithNamespaceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.Namespace = "api/v1"; + options.UseRelativeLinks = true; + options.DefaultPageSize = new PageSize(10); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Get_primary_resource_by_ID_returns_links() + { + // Arrange + var person = new Person(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/people/" + person.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + + responseDocument.SingleData.Relationships["todoItems"].Links.Self.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); + responseDocument.SingleData.Relationships["todoItems"].Links.Related.Should().Be($"/api/v1/people/{person.StringId}/todoItems"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_links() + { + // Arrange + var person = new Person + { + TodoItems = new HashSet + { + new TodoItem() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/people?include=todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.First.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.Last.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/api/v1/todoItems/{person.TodoItems.ElementAt(0).StringId}"); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_links() + { + // Arrange + var person = new Person + { + TodoItems = new HashSet + { + new TodoItem() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // TODO: @ThisPR links/related was removed from the expected response body here, which violates the json:api spec. + + responseDocument.Links.Self.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); + responseDocument.Links.First.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs new file mode 100644 index 0000000000..ca90bf960e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -0,0 +1,28 @@ +using System; +using FluentAssertions; +using FluentAssertions.Primitives; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests +{ + public static class ObjectAssertionsExtensions + { + /// + /// Used to assert on a nullable column, whose value is returned as in json:api response body. + /// + public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expected, string because = "", + params object[] becauseArgs) + { + if (expected == null) + { + source.Subject.Should().BeNull(because, becauseArgs); + } + else + { + // We lose a little bit of precision (milliseconds) on roundtrip through PostgreSQL database. + + var value = new DateTimeOffset((DateTime) source.Subject); + value.Should().BeCloseTo(expected.Value, because: because, becauseArgs: becauseArgs); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs index cc695d45b9..a925389515 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -557,6 +557,51 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Next.Should().Be($"http://localhost/api/v1/blogs/{blog.StringId}/articles?page[number]=2"); } + [Fact] + public async Task Returns_all_resources_when_paging_is_disabled() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = null; + + var blog = new Blog + { + Articles = new List
() + }; + + for (int index = 0; index < 25; index++) + { + blog.Articles.Add(new Article + { + Caption = $"Item {index:D3}" + }); + } + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/articles"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(25); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost" + route); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + } + [Theory] [InlineData(1, 1, 4, null, 2)] [InlineData(2, 1, 4, 1, 3)] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs new file mode 100644 index 0000000000..fa54ac50f7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs @@ -0,0 +1,246 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Fetching +{ + public sealed class FetchRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public FetchRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_HasOne_relationship() + { + var workItem = _fakers.WorkItem.Generate(); + workItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("userAccounts"); + responseDocument.SingleData.Id.Should().Be(workItem.Assignee.StringId); + responseDocument.SingleData.Attributes.Should().BeNull(); + } + + [Fact] + public async Task Can_get_empty_HasOne_relationship() + { + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task Can_get_HasMany_relationship() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + userAccount.AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(userAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/userAccounts/{userAccount.StringId}/relationships/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(0).StringId); + item1.Type.Should().Be("workItems"); + item1.Attributes.Should().BeNull(); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(1).StringId); + item2.Type.Should().Be("workItems"); + item2.Attributes.Should().BeNull(); + } + + [Fact] + public async Task Can_get_empty_HasMany_relationship() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(userAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/userAccounts/{userAccount.StringId}/relationships/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task Can_get_HasManyThrough_relationship() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.WorkItemTags = new List + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(0).Tag.StringId); + item1.Type.Should().Be("workTags"); + item1.Attributes.Should().BeNull(); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(1).Tag.StringId); + item2.Type.Should().Be("workTags"); + item2.Attributes.Should().BeNull(); + } + + [Fact] + public async Task Can_get_empty_HasManyThrough_relationship() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_primary_type() + { + var route = "/doesNotExist/99999999/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_primary_ID() + { + var route = "/workItems/99999999/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_relationship_type() + { + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs new file mode 100644 index 0000000000..905a9e5f14 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -0,0 +1,371 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Fetching +{ + public sealed class FetchResourceTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public FetchResourceTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + var workItems = _fakers.WorkItem.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WorkItems.AddRange(workItems); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == workItems[0].StringId); + item1.Type.Should().Be("workItems"); + item1.Attributes["description"].Should().Be(workItems[0].Description); + item1.Attributes["dueAt"].Should().BeCloseTo(workItems[0].DueAt); + item1.Attributes["priority"].Should().Be(workItems[0].Priority.ToString("G")); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItems[1].StringId); + item2.Type.Should().Be("workItems"); + item2.Attributes["description"].Should().Be(workItems[1].Description); + item2.Attributes["dueAt"].Should().BeCloseTo(workItems[1].DueAt); + item2.Attributes["priority"].Should().Be(workItems[1].Priority.ToString("G")); + } + + [Fact] + public async Task Cannot_get_primary_resources_for_unknown_type() + { + // Arrange + var route = "/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems/" + workItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(workItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); + responseDocument.SingleData.Attributes["dueAt"].Should().BeCloseTo(workItem.DueAt); + responseDocument.SingleData.Attributes["priority"].Should().Be(workItem.Priority.ToString("G")); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_type() + { + // Arrange + var route = "/doesNotExist/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_ID() + { + // Arrange + var route = "/workItems/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Can_get_secondary_HasOne_resource() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("userAccounts"); + responseDocument.SingleData.Id.Should().Be(workItem.Assignee.StringId); + responseDocument.SingleData.Attributes["firstName"].Should().Be(workItem.Assignee.FirstName); + responseDocument.SingleData.Attributes["lastName"].Should().Be(workItem.Assignee.LastName); + } + + [Fact] + public async Task Can_get_unknown_secondary_HasOne_resource() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task Can_get_secondary_HasMany_resources() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + userAccount.AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(userAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/userAccounts/{userAccount.StringId}/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(0).StringId); + item1.Type.Should().Be("workItems"); + item1.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(0).Description); + item1.Attributes["dueAt"].Should().BeCloseTo(userAccount.AssignedItems.ElementAt(0).DueAt); + item1.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(0).Priority.ToString("G")); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(1).StringId); + item2.Type.Should().Be("workItems"); + item2.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(1).Description); + item2.Attributes["dueAt"].Should().BeCloseTo(userAccount.AssignedItems.ElementAt(1).DueAt); + item2.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(1).Priority.ToString("G")); + } + + [Fact] + public async Task Can_get_unknown_secondary_HasMany_resource() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(userAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/userAccounts/{userAccount.StringId}/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task Can_get_secondary_HasManyThrough_resources() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.WorkItemTags = new List + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(0).Tag.StringId); + item1.Type.Should().Be("workTags"); + item1.Attributes["text"].Should().Be(workItem.WorkItemTags.ElementAt(0).Tag.Text); + item1.Attributes["isBuiltIn"].Should().Be(workItem.WorkItemTags.ElementAt(0).Tag.IsBuiltIn); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(1).Tag.StringId); + item2.Type.Should().Be("workTags"); + item2.Attributes["text"].Should().Be(workItem.WorkItemTags.ElementAt(1).Tag.Text); + item2.Attributes["isBuiltIn"].Should().Be(workItem.WorkItemTags.ElementAt(1).Tag.IsBuiltIn); + } + + [Fact] + public async Task Can_get_unknown_secondary_HasManyThrough_resources() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_primary_type() + { + // Arrange + var route = "/doesNotExist/99999999/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() + { + // Arrange + var route = "/workItems/99999999/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_secondary_type() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + } +}