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 296fd559df..749df0a505 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -11,11 +11,4 @@ public class Tag : Identifiable [Attr] public TagColor Color { 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..26c42c29f2 100644 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ b/src/Examples/ReportsExample/Controllers/ReportsController.cs @@ -1,24 +1,24 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using ReportsExample.Models; - -namespace ReportsExample.Controllers -{ - [Route("api/[controller]")] - public class ReportsController : BaseJsonApiController - { +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using ReportsExample.Models; + +namespace ReportsExample.Controllers +{ + [Route("api/[controller]")] + public class ReportsController : BaseJsonApiController + { public ReportsController( IJsonApiOptions options, - ILoggerFactory loggerFactory, - IGetAllService getAll) - : base(options, loggerFactory, getAll) - { } - - [HttpGet] - public override async Task GetAsync() => await base.GetAsync(); - } -} + ILoggerFactory loggerFactory, + IGetAllService getAll) + : base(options, loggerFactory, getAll) + { } + + [HttpGet] + public override async Task GetAsync() => await base.GetAsync(); + } +} 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 97f7c15dd9..ac8d440489 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -720,7 +720,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); } } }