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/docs/internals/index.md b/docs/internals/index.md index 7e27842923..32dde9619d 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -1,3 +1,3 @@ # Internals -The section contains overviews for the inner workings of the JsonApiDotNetCore library. +This section contains overviews for the inner workings of the JsonApiDotNetCore library. diff --git a/docs/request-examples/index.md b/docs/request-examples/index.md index 58cba05f1c..4d82e95854 100644 --- a/docs/request-examples/index.md +++ b/docs/request-examples/index.md @@ -45,22 +45,22 @@ _Note that cURL requires "[" and "]" in URLs to be escaped._ # Writing data -### Create +### Create resource [!code-ps[REQUEST](010_CREATE_Person.ps1)] [!code-json[RESPONSE](010_CREATE_Person_Response.json)] -### Create with relationship +### Create resource with relationship [!code-ps[REQUEST](011_CREATE_Book-with-Author.ps1)] [!code-json[RESPONSE](011_CREATE_Book-with-Author_Response.json)] -### Update +### Update resource [!code-ps[REQUEST](012_PATCH_Book.ps1)] [!code-json[RESPONSE](012_PATCH_Book_Response.json)] -### Delete +### Delete resource [!code-ps[REQUEST](013_DELETE_Book.ps1)] [!code-json[RESPONSE](013_DELETE_Book_Response.json)] diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md index eb59c81d7c..0f953d06a4 100644 --- a/docs/usage/extensibility/repositories.md +++ b/docs/usage/extensibility/repositories.md @@ -3,7 +3,7 @@ If you want to use a data access technology other than Entity Framework Core, you can create an implementation of `IResourceRepository`. If you only need minor changes you can override the methods defined in `EntityFrameworkCoreRepository`. -The repository should then be added to the service collection in Startup.cs. +The repository should then be registered in Startup.cs. ```c# public void ConfigureServices(IServiceCollection services) @@ -12,6 +12,21 @@ public void ConfigureServices(IServiceCollection services) } ``` +In v4.0 we introduced an extension method that you can use to register a resource repository on all of its JsonApiDotNetCore interfaces. +This is helpful when you implement a subset of the resource interfaces and want to register them all in one go. + +Note: If you're using service discovery, this happens automatically. + +```c# +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddResourceRepository(); + } +} +``` + A sample implementation that performs authorization might look like this. All of the methods in EntityFrameworkCoreRepository will use the `GetAll()` method to get the `DbSet`, so this is a good method to apply filters such as user or tenant authorization. diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index c99911df42..2c3b612e74 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -115,7 +115,7 @@ IResourceService PATCH /{id}/relationships/{relationship} ``` -In order to take advantage of these interfaces you first need to inject the service for each implemented interface. +In order to take advantage of these interfaces you first need to register the service for each implemented interface. ```c# public class ArticleService : ICreateService
, IDeleteService
@@ -136,6 +136,8 @@ public class Startup In v3.0 we introduced an extension method that you can use to register a resource service on all of its JsonApiDotNetCore interfaces. This is helpful when you implement a subset of the resource interfaces and want to register them all in one go. +Note: If you're using service discovery, this happens automatically. + ```c# public class Startup { diff --git a/docs/usage/filtering.md b/docs/usage/reading/filtering.md similarity index 100% rename from docs/usage/filtering.md rename to docs/usage/reading/filtering.md diff --git a/docs/usage/including-relationships.md b/docs/usage/reading/including-relationships.md similarity index 100% rename from docs/usage/including-relationships.md rename to docs/usage/reading/including-relationships.md diff --git a/docs/usage/pagination.md b/docs/usage/reading/pagination.md similarity index 100% rename from docs/usage/pagination.md rename to docs/usage/reading/pagination.md diff --git a/docs/usage/sorting.md b/docs/usage/reading/sorting.md similarity index 100% rename from docs/usage/sorting.md rename to docs/usage/reading/sorting.md diff --git a/docs/usage/sparse-fieldset-selection.md b/docs/usage/reading/sparse-fieldset-selection.md similarity index 100% rename from docs/usage/sparse-fieldset-selection.md rename to docs/usage/reading/sparse-fieldset-selection.md diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index b501c6e984..3976e93ebb 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -20,9 +20,6 @@ public class TodoItem : Identifiable } ``` -The convention used to locate the foreign key property (e.g. `OwnerId`) can be changed on -the @JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_RelatedIdMapper - ## HasMany ```c# diff --git a/docs/usage/toc.md b/docs/usage/toc.md index ff9476d800..0dc75882c4 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -3,13 +3,20 @@ ## [Relationships](resources/relationships.md) ## [Resource Definitions](resources/resource-definitions.md) +# Reading data +## [Filtering](reading/filtering.md) +## [Sorting](reading/sorting.md) +## [Pagination](reading/pagination.md) +## [Sparse Fieldset Selection](reading/sparse-fieldset-selection.md) +## [Including Relationships](reading/including-relationships.md) + +# Writing data +## [Creating](writing/creating.md) +## [Updating](writing/updating.md) +## [Deleting](writing/deleting.md) + # [Resource Graph](resource-graph.md) # [Options](options.md) -# [Filtering](filtering.md) -# [Sorting](sorting.md) -# [Pagination](pagination.md) -# [Sparse Fieldset Selection](sparse-fieldset-selection.md) -# [Including Relationships](including-relationships.md) # [Routing](routing.md) # [Errors](errors.md) # [Metadata](meta.md) diff --git a/docs/usage/writing/creating.md b/docs/usage/writing/creating.md new file mode 100644 index 0000000000..7cda3dd61e --- /dev/null +++ b/docs/usage/writing/creating.md @@ -0,0 +1,74 @@ +# Creating resources + +A single resource can be created by sending a POST request. The next example creates a new article: + +```http +POST /articles HTTP/1.1 + +{ + "data": { + "type": "articles", + "attributes": { + "caption": "A new article!", + "url": "www.website.com" + } + } +} +``` + +When using client-generated IDs and only attributes from the request have changed, the server returns `204 No Content`. +Otherwise, the server returns `200 OK`, along with the updated resource and its newly assigned ID. + +In both cases, a `Location` header is returned that contains the URL to the new resource. + +# Creating resources with relationships + +It is possible to create a new resource and establish relationships to existing resources in a single request. +The example below creates an article and sets both its owner and tags. + +```http +POST /articles HTTP/1.1 + +{ + "data": { + "type": "articles", + "attributes": { + "caption": "A new article!" + }, + "relationships": { + "author": { + "data": { + "type": "person", + "id": "101" + } + }, + "tags": { + "data": [ + { + "type": "tag", + "id": "123" + }, + { + "type": "tag", + "id": "456" + } + ] + } + } + } +} +``` + +# Response body + +POST requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields`. For example: + +```http +POST /articles?include=owner&fields[owner]=firstName HTTP/1.1 + +{ + ... +} +``` + +After the resource has been created on the server, it is re-fetched from the database using the specified query string parameters and returned to the client. diff --git a/docs/usage/writing/deleting.md b/docs/usage/writing/deleting.md new file mode 100644 index 0000000000..15c05b622a --- /dev/null +++ b/docs/usage/writing/deleting.md @@ -0,0 +1,9 @@ +# Deleting resources + +A single resource can be deleted using a DELETE request. The next example deletes an article: + +```http +DELETE /articles/1 HTTP/1.1 +``` + +This returns `204 No Content` if the resource was successfully deleted. Alternatively, if the resource does not exist, `404 Not Found` is returned. diff --git a/docs/usage/writing/updating.md b/docs/usage/writing/updating.md new file mode 100644 index 0000000000..447a29550a --- /dev/null +++ b/docs/usage/writing/updating.md @@ -0,0 +1,137 @@ +# Updating resources + +## Updating resource attributes + +To modify the attributes of a single resource, send a PATCH request. The next example changes the article caption: + +```http +POST /articles HTTP/1.1 + +{ + "data": { + "type": "articles", + "id": "1", + "attributes": { + "caption": "This has changed" + } + } +} +``` + +This preserves the values of all other unsent attributes and is called a *partial patch*. + +When only the attributes that were sent in the request have changed, the server returns `204 No Content`. +But if additional attributes have changed (for example, by a database trigger that refreshes the last-modified date) the server returns `200 OK`, along with all attributes of the updated resource. + +## Updating resource relationships + +Besides its attributes, the relationships of a resource can be changed using a PATCH request too. +Note that all resources being assigned in a relationship must already exist. + +When updating a HasMany relationship, the existing set is replaced by the new set. See below on how to add/remove resources. + +The next example replaces both the owner and tags of an article. + +```http +PATCH /articles/1 HTTP/1.1 + +{ + "data": { + "type": "articles", + "id": "1", + "relationships": { + "author": { + "data": { + "type": "person", + "id": "101" + } + }, + "tags": { + "data": [ + { + "type": "tag", + "id": "123" + }, + { + "type": "tag", + "id": "456" + } + ] + } + } + } +} +``` + +A HasOne relationship can be cleared by setting `data` to `null`, while a HasMany relationship can be cleared by setting it to an empty array. + +By combining the examples above, both attributes and relationships can be updated using a single PATCH request. + +## Response body + +PATCH requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields`. For example: + +```http +PATCH /articles/1?include=owner&fields[owner]=firstName HTTP/1.1 + +{ + ... +} +``` + +After the resource has been updated on the server, it is re-fetched from the database using the specified query string parameters and returned to the client. +Note this only has an effect when `200 OK` is returned. + +# Updating relationships + +Although relationships can be modified along with resources (as described above), it is also possible to change a single relationship using a relationship URL. +The same rules for clearing the relationship apply. And similar to PATCH on a resource URL, updating a HasMany relationship replaces the existing set. + +The next example changes just the owner of an article, by sending a PATCH request to its relationship URL. + +```http +PATCH /articles/1/relationships/owner HTTP/1.1 + +{ + "data": { + "type": "person", + "id": "101" + } +} +``` + +The server returns `204 No Content` when the update is successful. + +## Changing HasMany relationships + +The POST and DELETE verbs can be used on HasMany relationship URLs to add or remove resources to/from an existing set without replacing it. + +The next example adds another tag to the existing set of tags of an article: + +```http +POST /articles/1/relationships/tags HTTP/1.1 + +{ + "data": [ + { + "type": "tag", + "id": "789" + } + ] +} +``` + +Likewise, the next example removes a single tag from the set of tags of an article: + +```http +DELETE /articles/1/relationships/tags HTTP/1.1 + +{ + "data": [ + { + "type": "tag", + "id": "789" + } + ] +} +``` diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs index 152628ad96..62fa1e96c3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs @@ -1,50 +1,16 @@ -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - public sealed class PassportsController : BaseJsonApiController + public sealed class PassportsController : JsonApiController { - public PassportsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) + public PassportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public override async Task GetAsync() => await base.GetAsync(); - - [HttpGet("{id}")] - public async Task GetAsync(string id) - { - int idValue = HexadecimalObfuscationCodec.Decode(id); - return await base.GetAsync(idValue); - } - - [HttpPatch("{id}")] - public async Task PatchAsync(string id, [FromBody] Passport resource) - { - int idValue = HexadecimalObfuscationCodec.Decode(id); - return await base.PatchAsync(idValue, resource); - } - - [HttpPost] - public override async Task PostAsync([FromBody] Passport resource) - { - return await base.PostAsync(resource); - } - - [HttpDelete("{id}")] - public async Task DeleteAsync(string id) { - int idValue = HexadecimalObfuscationCodec.Decode(id); - return await base.DeleteAsync(idValue); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index fb75b37226..16fd697688 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..c6d945372e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -40,16 +41,16 @@ public TodoItemsTestController( [HttpGet("{id}")] public override async Task GetAsync(int id) => await base.GetAsync(id); - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(int id, string relationshipName) - => await base.GetRelationshipAsync(id, relationshipName); - [HttpGet("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(int id, string relationshipName) => await base.GetSecondaryAsync(id, relationshipName); + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(int id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); + [HttpPost] - public override async Task PostAsync(TodoItem resource) + public override async Task PostAsync([FromBody] TodoItem resource) { await Task.Yield(); @@ -59,6 +60,11 @@ public override async Task PostAsync(TodoItem resource) }); } + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + int id, string relationshipName, [FromBody] ISet secondaryResourceIds) + => await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds); + [HttpPatch("{id}")] public override async Task PatchAsync(int id, [FromBody] TodoItem resource) { @@ -69,8 +75,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) @@ -79,5 +85,9 @@ public override async Task DeleteAsync(int id) return NotFound(); } + + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(int id, string relationshipName, [FromBody] ISet secondaryResourceIds) + => await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds); } } diff --git a/src/Examples/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..58eaeb5f9f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -14,16 +14,6 @@ public class Passport : Identifiable private readonly ISystemClock _systemClock; private int? _socialSecurityNumber; - protected override string GetStringId(int value) - { - return HexadecimalObfuscationCodec.Encode(value); - } - - protected override int GetTypedId(string value) - { - return HexadecimalObfuscationCodec.Decode(value); - } - [Attr] public int? SocialSecurityNumber { @@ -51,7 +41,7 @@ public int? SocialSecurityNumber [NotMapped] public string BirthCountryName { - get => BirthCountry.Name; + get => BirthCountry?.Name; set { BirthCountry ??= new Country(); diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index db610ac0f2..b5d67fb5a0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -57,20 +57,17 @@ public string FirstName [HasOne] public PersonRole Role { get; set; } - public int? PersonRoleId { get; set; } [HasOne] public TodoItem OneToOneTodoItem { get; set; } [HasOne] public TodoItem StakeHolderTodoItem { get; set; } - public int? StakeHolderTodoItemId { get; set; } [HasOne(Links = LinkTypes.All, CanInclude = false)] public TodoItem UnIncludeableItem { get; set; } [HasOne] public Passport Passport { get; set; } - public int? PassportId { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index dbee65a188..ace6f23711 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -18,11 +18,4 @@ public class Tag : Identifiable public ISet
Articles { get; set; } public ISet ArticleTags { get; set; } } - - public enum TagColor - { - Red, - Green, - Blue - } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs new file mode 100644 index 0000000000..8ae4552afe --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExample.Models +{ + public enum TagColor + { + Red, + Green, + Blue + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 782b2521be..64afada036 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -7,11 +7,6 @@ namespace JsonApiDotNetCoreExample.Models { public class TodoItem : Identifiable, IIsLockable { - public TodoItem() - { - GuidProperty = Guid.NewGuid(); - } - public bool IsLocked { get; set; } [Attr] @@ -20,9 +15,6 @@ public TodoItem() [Attr] public long Ordinal { get; set; } - [Attr] - public Guid GuidProperty { get; set; } - [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)] public string AlwaysChangingValue { @@ -36,21 +28,12 @@ public string AlwaysChangingValue [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] public DateTime? AchievedDate { get; set; } - [Attr] - public DateTime? UpdatedDate { get; set; } - [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public string CalculatedValue => "calculated"; [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public DateTimeOffset? OffsetDate { get; set; } - public int? OwnerId { get; set; } - - public int? AssigneeId { get; set; } - - public Guid? CollectionId { get; set; } - [HasOne] public Person Owner { get; set; } @@ -60,8 +43,6 @@ public string AlwaysChangingValue [HasOne] public Person OneToOnePerson { get; set; } - public int? OneToOnePersonId { get; set; } - [HasMany] public ISet StakeHolders { get; set; } @@ -69,14 +50,10 @@ public string AlwaysChangingValue public TodoItemCollection Collection { get; set; } // cyclical to-one structure - public int? DependentOnTodoId { get; set; } - [HasOne] public TodoItem DependentOnTodo { get; set; } // cyclical to-many structure - public int? ParentTodoId {get; set;} - [HasOne] public TodoItem ParentTodo { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs index 9b0a515f25..4f3c88e152 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs @@ -16,7 +16,5 @@ public sealed class TodoItemCollection : Identifiable [HasOne] public Person Owner { get; set; } - - public int? OwnerId { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 430b65e27e..7876fbb95f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -22,9 +22,11 @@ public CustomArticleService( IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) - : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) + ISecondaryResourceResolver secondaryResourceResolver, + IResourceHookExecutorFacade hookExecutor) + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceFactory, secondaryResourceResolver, + 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..574695700b 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -12,11 +12,9 @@ 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, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index c0761187b1..098a580579 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -12,11 +12,9 @@ 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, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 8eeae612c7..a5c34e0966 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; @@ -30,7 +31,7 @@ public async Task> GetAsync() public async Task GetAsync(int id) { var query = await QueryAsync(async connection => - await connection.QueryAsync(@"select * from ""WorkItems"" where ""Id""=@id", new { id })); + await connection.QueryAsync(@"select * from ""WorkItems"" where ""Id""=@id", new {id})); return query.Single(); } @@ -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(); } @@ -49,24 +50,39 @@ public async Task CreateAsync(WorkItem resource) { return (await QueryAsync(async connection => { - var query = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values (@description, @isLocked, @ordinal, @uniqueId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; - var result = await connection.QueryAsync(query, new { description = resource.Title, ordinal = resource.DurationInHours, uniqueId = resource.ProjectId, isLocked = resource.IsBlocked }); - return result; + var query = + @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + + @"(@description, @isLocked, @ordinal, @uniqueId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; + + return await connection.QueryAsync(query, new + { + description = resource.Title, ordinal = resource.DurationInHours, uniqueId = resource.ProjectId, isLocked = resource.IsBlocked + }); })).SingleOrDefault(); } - public async Task DeleteAsync(int id) + public Task AddToToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) { - await QueryAsync(async connection => - await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new { id })); + throw new NotImplementedException(); } - public Task UpdateAsync(int id, WorkItem requestResource) + public Task UpdateAsync(int id, WorkItem resource) { throw new NotImplementedException(); } - public Task UpdateRelationshipAsync(int id, string relationshipName, object relationships) + public Task SetRelationshipAsync(int primaryId, string relationshipName, object secondaryResourceIds) + { + throw new NotImplementedException(); + } + + public async Task DeleteAsync(int id) + { + await QueryAsync(async connection => + await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new {id})); + } + + public Task RemoveFromToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) { throw new NotImplementedException(); } diff --git a/src/Examples/ReportsExample/Controllers/ReportsController.cs b/src/Examples/ReportsExample/Controllers/ReportsController.cs index c80aba4680..74bb2301a5 100644 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ b/src/Examples/ReportsExample/Controllers/ReportsController.cs @@ -11,8 +11,8 @@ namespace ReportsExample.Controllers [Route("api/[controller]")] public class ReportsController : BaseJsonApiController { - public ReportsController( - IJsonApiOptions options, + public ReportsController( + IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll) : base(options, loggerFactory, getAll) diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 0d4d20f59c..b176df1ead 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -25,9 +25,9 @@ public static void UseJsonApi(this IApplicationBuilder builder) if (builder == null) throw new ArgumentNullException(nameof(builder)); using var scope = builder.ApplicationServices.GetRequiredService().CreateScope(); - var inverseRelationshipResolver = scope.ServiceProvider.GetRequiredService(); - inverseRelationshipResolver.Resolve(); - + var inverseNavigationResolver = scope.ServiceProvider.GetRequiredService(); + inverseNavigationResolver.Resolve(); + var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); jsonApiApplicationBuilder.ConfigureMvcOptions = options => { diff --git a/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs similarity index 72% rename from src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs rename to src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs index b15afea2ce..227901a8be 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs @@ -3,20 +3,19 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Responsible for populating the property. + /// Responsible for populating . /// /// 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 IInverseNavigationResolver { /// - /// 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/InverseNavigationResolver.cs similarity index 87% rename from src/JsonApiDotNetCore/Configuration/InverseRelationships.cs rename to src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index f8164b490e..bc93eff7e0 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseRelationships.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCore.Configuration { /// - public class InverseRelationships : IInverseRelationships + public class InverseNavigationResolver : IInverseNavigationResolver { private readonly IResourceContextProvider _resourceContextProvider; private readonly IEnumerable _dbContextResolvers; - public InverseRelationships(IResourceContextProvider resourceContextProvider, + public InverseNavigationResolver(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..a6281f38a3 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -139,17 +139,14 @@ 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(); + _services.AddScoped(); } private void AddMiddlewareLayer() @@ -175,55 +172,38 @@ 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<,>)); - - _services.AddScoped(typeof(IDeleteService<>), typeof(JsonApiResourceService<>)); - _services.AddScoped(typeof(IDeleteService<,>), typeof(JsonApiResourceService<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, + typeof(JsonApiResourceService<>), typeof(JsonApiResourceService<,>)); + } - _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 +238,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..e679e464a9 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) @@ -187,22 +187,42 @@ private IReadOnlyCollection GetRelationships(Type resourc var throughProperties = throughType.GetProperties(); // ArticleTag.Article - hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType.IsAssignableFrom(resourceType)) - ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {resourceType}"); + if (hasManyThroughAttribute.LeftPropertyName != null) + { + // In case of a self-referencing many-to-many relationship, the left property name must be specified. + hasManyThroughAttribute.LeftProperty = hasManyThroughAttribute.ThroughType.GetProperty(hasManyThroughAttribute.LeftPropertyName) + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property named '{hasManyThroughAttribute.LeftPropertyName}'."); + } + else + { + // In case of a non-self-referencing many-to-many relationship, we just pick the single compatible type. + hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType.IsAssignableFrom(resourceType)) + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property to type '{resourceType}'."); + } // ArticleTag.ArticleId - var leftIdPropertyName = 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}"); + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a relationship ID property to type '{resourceType}' with name '{leftIdPropertyName}'."); // ArticleTag.Tag - hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.RightType) - ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}"); + if (hasManyThroughAttribute.RightPropertyName != null) + { + // In case of a self-referencing many-to-many relationship, the right property name must be specified. + hasManyThroughAttribute.RightProperty = hasManyThroughAttribute.ThroughType.GetProperty(hasManyThroughAttribute.RightPropertyName) + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property named '{hasManyThroughAttribute.RightPropertyName}'."); + } + else + { + // In case of a non-self-referencing many-to-many relationship, we just pick the single compatible type. + hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.RightType) + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property to type '{hasManyThroughAttribute.RightType}'."); + } // ArticleTag.TagId - var rightIdPropertyName = 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}"); + ?? throw new InvalidConfigurationException($"'{throughType}' does not contain a relationship ID property to type '{hasManyThroughAttribute.RightType}' with name '{rightIdPropertyName}'."); } } diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 1c1e77f436..841ccc8374 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Client.Internal; using JsonApiDotNetCore.Services; @@ -78,24 +79,42 @@ public static IServiceCollection AddClientSerialization(this IServiceCollection /// /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, - /// such as , and various others. + /// such as , and the various others. /// - /// public static IServiceCollection AddResourceService(this IServiceCollection services) { if (services == null) throw new ArgumentNullException(nameof(services)); - var typeImplementsAnExpectedInterface = false; - var serviceImplementationType = typeof(TService); - var resourceDescriptor = TryGetResourceTypeFromServiceImplementation(serviceImplementationType); + RegisterForConstructedType(services, typeof(TService), ServiceDiscoveryFacade.ServiceInterfaces); + + return services; + } + + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource repository interfaces, + /// such as and . + /// + public static IServiceCollection AddResourceRepository(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + RegisterForConstructedType(services, typeof(TRepository), ServiceDiscoveryFacade.RepositoryInterfaces); + + return services; + } + + private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable openGenericInterfaces) + { + bool seenCompatibleInterface = false; + var resourceDescriptor = TryGetResourceTypeFromServiceImplementation(implementationType); if (resourceDescriptor != null) { - foreach (var openGenericType in ServiceDiscoveryFacade.ServiceInterfaces) + foreach (var openGenericInterface in openGenericInterfaces) { // A shorthand interface is one where the ID type is omitted. // e.g. IResourceService is the shorthand for IResourceService - var isShorthandInterface = openGenericType.GetTypeInfo().GenericTypeParameters.Length == 1; + var isShorthandInterface = openGenericInterface.GetTypeInfo().GenericTypeParameters.Length == 1; if (isShorthandInterface && resourceDescriptor.IdType != typeof(int)) { // We can't create a shorthand for ID types other than int. @@ -103,21 +122,21 @@ public static IServiceCollection AddResourceService(this IServiceColle } var constructedType = isShorthandInterface - ? openGenericType.MakeGenericType(resourceDescriptor.ResourceType) - : openGenericType.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); + ? openGenericInterface.MakeGenericType(resourceDescriptor.ResourceType) + : openGenericInterface.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); - if (constructedType.IsAssignableFrom(serviceImplementationType)) + if (constructedType.IsAssignableFrom(implementationType)) { - services.AddScoped(constructedType, serviceImplementationType); - typeImplementsAnExpectedInterface = true; + services.AddScoped(constructedType, implementationType); + seenCompatibleInterface = true; } } } - if (!typeImplementsAnExpectedInterface) - throw new InvalidConfigurationException($"{serviceImplementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); - - return services; + if (!seenCompatibleInterface) + { + throw new InvalidConfigurationException( + $"{implementationType} does not implement any of the expected JsonApiDotNetCore interfaces.");} } private static ResourceDescriptor TryGetResourceTypeFromServiceImplementation(Type serviceType) 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..310495d499 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,54 +111,56 @@ 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)) + if (!_options.AllowClientGeneratedIds && resource.StringId != null) throw new ResourceIdInPostRequestNotAllowedException(); if (_options.ValidateModelState && !ModelState.IsValid) @@ -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..5d56ceeace 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) { } /// @@ -48,21 +51,27 @@ 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/IHasMultipleErrors.cs b/src/JsonApiDotNetCore/Errors/IHasMultipleErrors.cs new file mode 100644 index 0000000000..87e91922e4 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/IHasMultipleErrors.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + public interface IHasMultipleErrors + { + public IReadOnlyCollection Errors { get; } + } +} diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index d663a9bad8..51ddea6a09 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.Errors /// /// The error that is thrown when model state validation fails. /// - public class InvalidModelStateException : Exception + public class InvalidModelStateException : Exception, IHasMultipleErrors { public IReadOnlyCollection Errors { get; } diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index 1c91f34e9e..e9f2bf0a75 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using System.Text; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Errors @@ -9,44 +10,35 @@ namespace JsonApiDotNetCore.Errors /// public sealed class InvalidRequestBodyException : JsonApiException { - private readonly string _details; - private 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.", + Detail = FormatErrorDetail(details, requestBody, innerException) }, innerException) { - _details = details; - _requestBody = requestBody; - - UpdateErrorDetail(); } - private void UpdateErrorDetail() + private static string FormatErrorDetail(string details, string requestBody, Exception innerException) { - string text = _details ?? InnerException?.Message; + var builder = new StringBuilder(); + builder.Append(details ?? innerException?.Message); - if (_requestBody != null) + if (requestBody != null) { - if (text != null) + if (builder.Length > 0) { - text += " - "; + builder.Append(" - "); } - text += "Request body: <<" + _requestBody + ">>"; + builder.Append("Request body: <<"); + builder.Append(requestBody); + builder.Append(">>"); } - Error.Detail = text; - } - - public void SetRequestBody(string requestBody) - { - _requestBody = requestBody; - UpdateErrorDetail(); + return builder.Length > 0 ? builder.ToString() : null; } } } 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/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/ResourcesInRelationshipsNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs new file mode 100644 index 0000000000..7c7a9e5a2a --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.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 ResourcesInRelationshipsNotFoundException : Exception, IHasMultipleErrors + { + public IReadOnlyCollection Errors { get; } + + public ResourcesInRelationshipsNotFoundException(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..5e488e562c --- /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; + + void BeforeUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable; + + void AfterUpdateRelationshipAsync(TResource resource) + 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..a849a4f736 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs @@ -0,0 +1,93 @@ +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 never executes any callbacks, 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 void BeforeUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable + { + } + + public void AfterUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable + { + } + + 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..362c186bf6 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs @@ -0,0 +1,139 @@ +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 void BeforeUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable + { + _resourceHookExecutor.BeforeUpdate(ToList(resource), ResourcePipeline.PatchRelationship); + } + + public void AfterUpdateRelationshipAsync(TResource resource) + where TResource : class, IIdentifiable + { + _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/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 24253becd3..9ae87879fa 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -28,4 +28,8 @@ + + + + diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index c0f3e2f6e0..1207efbb95 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -66,9 +66,9 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) { if (exception == null) throw new ArgumentNullException(nameof(exception)); - if (exception is InvalidModelStateException modelStateException) + if (exception is IHasMultipleErrors exceptionWithMultipleErrors) { - return new ErrorDocument(modelStateException.Errors); + return new ErrorDocument(exceptionWithMultipleErrors.Errors); } Error error = exception is JsonApiException jsonApiException 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/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index dd2657e6a2..15d84bd50d 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -13,12 +13,32 @@ public interface IQueryLayerComposer /// /// Builds a top-level filter from constraints, used to determine total resource count. /// - FilterExpression GetTopFilter(); + FilterExpression GetTopFilterFromConstraints(); + + /// + /// Builds a filter to match on the specified IDs. + /// + FilterExpression GetFilterOnResourceIds(ICollection ids, ResourceContext resourceContext); + + /// + /// Builds a join table filter, which matches on the specified IDs. + /// + FilterExpression GetJoinTableFilter(TLeftId leftId, ICollection rightIds, HasManyThroughAttribute relationship); /// /// Collects constraints and builds a out of them, used to retrieve the actual resources. /// - QueryLayer Compose(ResourceContext requestResource); + QueryLayer ComposeFromConstraints(ResourceContext requestResource); + + /// + /// Collects constraints and builds a out of them, used to retrieve one resource. + /// + QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection); + + /// + /// Collects constraints and builds the secondary layer for a relationship endpoint. + /// + QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext); /// /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. @@ -27,9 +47,8 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc TId primaryId, RelationshipAttribute secondaryRelationship); /// - /// Gets the secondary projection for a relationship endpoint. + /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete request. /// - IDictionary GetSecondaryProjectionForRelationshipEndpoint( - ResourceContext secondaryResourceContext); + QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index 0f2904fc9f..791089f00d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -295,7 +295,9 @@ protected LiteralConstantExpression ParseConstant() private string DeObfuscateStringId(Type resourceType, string stringId) { - return TypeHelper.ConvertStringIdToTypedId(resourceType, stringId, _resourceFactory).ToString(); + var tempResource = _resourceFactory.CreateInstance(resourceType); + tempResource.StringId = stringId; + return tempResource.GetTypedId().ToString(); } protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 58d723f13f..d7c028fb06 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -16,54 +16,83 @@ public class QueryLayerComposer : IQueryLayerComposer private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IJsonApiOptions _options; private readonly IPaginationContext _paginationContext; + private readonly ITargetedFields _targetedFields; public QueryLayerComposer( IEnumerable constraintProviders, IResourceContextProvider resourceContextProvider, IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiOptions options, - IPaginationContext paginationContext) + IPaginationContext paginationContext, + ITargetedFields targetedFields) { _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); _options = options ?? throw new ArgumentNullException(nameof(options)); _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); } /// - public FilterExpression GetTopFilter() + public FilterExpression GetTopFilterFromConstraints() { - var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); + var constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); var topFilters = constraints - .Where(c => c.Scope == null) - .Select(c => c.Expression) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) .OfType() .ToArray(); - if (!topFilters.Any()) + if (topFilters.Length > 1) { - return null; + return new LogicalExpression(LogicalOperator.And, topFilters); } - if (topFilters.Length == 1) - { - return topFilters[0]; - } + return topFilters.Length == 1 ? topFilters[0] : null; + } + + /// + public FilterExpression GetFilterOnResourceIds(ICollection ids, ResourceContext resourceContext) + { + if (ids == null) throw new ArgumentNullException(nameof(ids)); + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); - return new LogicalExpression(LogicalOperator.And, topFilters); + var baseFilter = GetFilter(Array.Empty(), resourceContext); + + var idAttribute = GetIdAttribute(resourceContext); + return CreateFilterByIds(ids, idAttribute, baseFilter); } /// - public QueryLayer Compose(ResourceContext requestResource) + public FilterExpression GetJoinTableFilter(TLeftId leftId, ICollection rightIds, + HasManyThroughAttribute relationship) { - if (requestResource == null) + var pseudoLeftIdAttribute = new AttrAttribute { - throw new ArgumentNullException(nameof(requestResource)); - } + Property = relationship.LeftIdProperty, + PublicName = relationship.LeftIdProperty.Name + }; + + var pseudoRightIdAttribute = new AttrAttribute + { + Property = relationship.RightIdProperty, + PublicName = relationship.RightIdProperty.Name + }; + + var leftFilter = CreateFilterByIds(new[] {leftId}, pseudoLeftIdAttribute, null); + var rightFilter = CreateFilterByIds(rightIds, pseudoRightIdAttribute, null); + + return new LogicalExpression(LogicalOperator.And, new[] {leftFilter, rightFilter}); + } + + /// + public QueryLayer ComposeFromConstraints(ResourceContext requestResource) + { + if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); - var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); + var constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); var topLayer = ComposeTopLayer(constraints, requestResource); topLayer.Include = ComposeChildren(topLayer, constraints); @@ -74,8 +103,8 @@ public QueryLayer Compose(ResourceContext requestResource) private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceContext resourceContext) { var expressionsInTopScope = constraints - .Where(c => c.Scope == null) - .Select(expressionInScope => expressionInScope.Expression) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) .ToArray(); var topPagination = GetPagination(expressionsInTopScope, resourceContext); @@ -94,11 +123,11 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R }; } - private IncludeExpression ComposeChildren(QueryLayer topLayer, ExpressionInScope[] constraints) + private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection constraints) { var include = constraints - .Where(c => c.Scope == null) - .Select(expressionInScope => expressionInScope.Expression).OfType() + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression).OfType() .FirstOrDefault() ?? IncludeExpression.Empty; var includeElements = @@ -110,7 +139,7 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ExpressionInScope } private IReadOnlyCollection ProcessIncludeSet(IReadOnlyCollection includeElements, - QueryLayer parentLayer, ICollection parentRelationshipChain, ExpressionInScope[] constraints) + QueryLayer parentLayer, ICollection parentRelationshipChain, ICollection constraints) { includeElements = GetIncludeElements(includeElements, parentLayer.ResourceContext) ?? Array.Empty(); @@ -128,8 +157,9 @@ private IReadOnlyCollection ProcessIncludeSet(IReadOnl }; var expressionsInCurrentScope = constraints - .Where(c => c.Scope != null && c.Scope.Fields.SequenceEqual(relationshipChain)) - .Select(expressionInScope => expressionInScope.Expression) + .Where(constraint => + constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) + .Select(constraint => constraint.Expression) .ToArray(); var resourceContext = @@ -162,7 +192,7 @@ private IReadOnlyCollection ProcessIncludeSet(IReadOnl return !updatesInChildren.Any() ? includeElements : ApplyIncludeElementUpdates(includeElements, updatesInChildren); } - private static IReadOnlyCollection ApplyIncludeElementUpdates(IReadOnlyCollection includeElements, + private static IReadOnlyCollection ApplyIncludeElementUpdates(IEnumerable includeElements, IDictionary> updatesInChildren) { var newIncludeElements = new List(includeElements); @@ -176,13 +206,68 @@ private static IReadOnlyCollection ApplyIncludeElement return newIncludeElements; } + /// + public QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection) + { + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); + + var idAttribute = GetIdAttribute(resourceContext); + + var queryLayer = ComposeFromConstraints(resourceContext); + queryLayer.Sort = null; + queryLayer.Pagination = null; + queryLayer.Filter = CreateFilterByIds(new[] {id}, idAttribute, queryLayer.Filter); + + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) + { + queryLayer.Projection = new Dictionary {{idAttribute, null}}; + } + else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null) + { + // Discard any top-level ?fields= or attribute exclusions from resource definition, because we need the full database row. + while (queryLayer.Projection.Any(pair => pair.Key is AttrAttribute)) + { + queryLayer.Projection.Remove(queryLayer.Projection.First(pair => pair.Key is AttrAttribute)); + } + } + + return queryLayer; + } + + /// + public QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext) + { + if (secondaryResourceContext == null) throw new ArgumentNullException(nameof(secondaryResourceContext)); + + var secondaryLayer = ComposeFromConstraints(secondaryResourceContext); + secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceContext); + secondaryLayer.Include = null; + + return secondaryLayer; + } + + private IDictionary GetProjectionForRelationship(ResourceContext secondaryResourceContext) + { + var secondaryIdAttribute = GetIdAttribute(secondaryResourceContext); + var sparseFieldSet = new SparseFieldSetExpression(new[] {secondaryIdAttribute}); + + var secondaryProjection = GetSparseFieldSetProjection(new[] {sparseFieldSet}, secondaryResourceContext) ?? new Dictionary(); + secondaryProjection[secondaryIdAttribute] = null; + + return secondaryProjection; + } + /// public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, RelationshipAttribute secondaryRelationship) { + if (secondaryLayer == null) throw new ArgumentNullException(nameof(secondaryLayer)); + if (primaryResourceContext == null) throw new ArgumentNullException(nameof(primaryResourceContext)); + if (secondaryRelationship == null) throw new ArgumentNullException(nameof(secondaryRelationship)); + var innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; - var primaryIdAttribute = primaryResourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + var primaryIdAttribute = GetIdAttribute(primaryResourceContext); var sparseFieldSet = new SparseFieldSetExpression(new[] { primaryIdAttribute }); var primaryProjection = GetSparseFieldSetProjection(new[] { sparseFieldSet }, primaryResourceContext) ?? new Dictionary(); @@ -194,7 +279,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, return new QueryLayer(primaryResourceContext) { Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), - Filter = IncludeFilterById(primaryId, primaryResourceContext, primaryFilter), + Filter = CreateFilterByIds(new[] {primaryId}, primaryIdAttribute, primaryFilter), Projection = primaryProjection }; } @@ -208,27 +293,46 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression r return new IncludeExpression(new[] {parentElement}); } - private FilterExpression IncludeFilterById(TId id, ResourceContext resourceContext, FilterExpression existingFilter) + private FilterExpression CreateFilterByIds(ICollection ids, AttrAttribute idAttribute, FilterExpression existingFilter) { - var primaryIdAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + var idChain = new ResourceFieldChainExpression(idAttribute); - FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + FilterExpression filter = null; + + if (ids.Count == 1) + { + var constant = new LiteralConstantExpression(ids.Single().ToString()); + filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + } + else if (ids.Count > 1) + { + var constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList(); + filter = new EqualsAnyOfExpression(idChain, constants); + } - return existingFilter == null - ? filterById - : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); + return filter == null ? existingFilter : + existingFilter == null ? filter : + new LogicalExpression(LogicalOperator.And, new[] {filter, existingFilter}); } - public IDictionary GetSecondaryProjectionForRelationshipEndpoint(ResourceContext secondaryResourceContext) + /// + public QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource) { - var secondaryIdAttribute = secondaryResourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - var sparseFieldSet = new SparseFieldSetExpression(new[] { secondaryIdAttribute }); + if (primaryResource == null) throw new ArgumentNullException(nameof(primaryResource)); - var secondaryProjection = GetSparseFieldSetProjection(new[] { sparseFieldSet }, secondaryResourceContext) ?? new Dictionary(); - secondaryProjection[secondaryIdAttribute] = null; + var includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)).ToArray(); - return secondaryProjection; + var primaryIdAttribute = GetIdAttribute(primaryResource); + + var primaryLayer = ComposeTopLayer(Array.Empty(), primaryResource); + primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : null; + primaryLayer.Sort = null; + primaryLayer.Pagination = null; + primaryLayer.Filter = CreateFilterByIds(new[] {id}, primaryIdAttribute, primaryLayer.Filter); + primaryLayer.Projection = null; + + return primaryLayer; } protected virtual IReadOnlyCollection GetIncludeElements(IReadOnlyCollection includeElements, ResourceContext resourceContext) @@ -262,7 +366,7 @@ protected virtual SortExpression GetSort(IReadOnlyCollection ex if (sort == null) { - var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + var idAttribute = GetIdAttribute(resourceContext); sort = new SortExpression(new[] {new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true)}); } @@ -300,10 +404,15 @@ protected virtual IDictionary GetSparseField return null; } - var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + var idAttribute = GetIdAttribute(resourceContext); attributes.Add(idAttribute); return attributes.Cast().ToDictionary(key => key, value => (QueryLayer) null); } + + private static AttrAttribute GetIdAttribute(ResourceContext resourceContext) + { + return resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + } } } diff --git a/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs b/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs new file mode 100644 index 0000000000..d5203ff537 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs @@ -0,0 +1,23 @@ +namespace JsonApiDotNetCore.Queries +{ + /// + /// Indicates how to override sparse fieldset selection coming from constraints. + /// + public enum TopFieldSelection + { + /// + /// Preserves the existing selection of attributes and/or relationships. + /// + PreserveExisting, + + /// + /// Preserves included relationships, but selects all resource attributes. + /// + WithAllAttributes, + + /// + /// Discards any included relationships and selects only resource ID. + /// + OnlyIdAttribute + } +} diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs new file mode 100644 index 0000000000..9ce5d31c1c --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -0,0 +1,15 @@ +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) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index ebc1ec6498..7d0df655f2 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,54 +1,64 @@ 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. + /// If not already tracked, attaches the specified resource to the change tracker in state. /// - 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; + } + + /// + /// Searches the change tracker for an entity that matches the type and ID of . + /// + public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + 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. + /// Calls for the specified type. /// - /// - /// - /// - /// 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 genericSetMethod = typeof(DbContext).GetMethod(nameof(DbContext.Set)); + if (genericSetMethod == null) + { + throw new InvalidOperationException($"Method '{nameof(DbContext)}.{nameof(DbContext.Set)}' does not exist."); + } - return await SafeTransactionProxy.GetOrCreateAsync(context.Database); + var constructedSetMethod = genericSetMethod.MakeGenericMethod(entityType); + return (IQueryable)constructedSetMethod.Invoke(dbContext, null); } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 2131ab8e6c..4521a89a3c 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -11,8 +12,16 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; +// TODO: @ThisPR Tests that cover relationship updates with required relationships. All relationships right now are currently optional. +// - Setting a required relationship to null +// - Creating resource with resource +// - One-to-one required / optional => what is the current behavior? +// tangent: +// - How and where to read EF Core metadata when "required-relationship-error" is triggered? namespace JsonApiDotNetCore.Repositories { /// @@ -24,7 +33,6 @@ 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 TraceLogWriter> _traceWriter; @@ -33,7 +41,6 @@ public EntityFrameworkCoreRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) @@ -43,9 +50,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)); + _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -80,6 +87,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 @@ -112,295 +125,375 @@ 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 UpdateRelationshipAsync(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) - { - if (relationshipAttr.InverseNavigation == null || trackedRelationshipValue == null) return; - if (relationshipAttr is HasOneAttribute hasOneAttr) - { - var relationEntry = _dbContext.Entry((IIdentifiable)trackedRelationshipValue); - if (IsHasOneRelationship(hasOneAttr.InverseNavigation, trackedRelationshipValue.GetType())) - relationEntry.Reference(hasOneAttr.InverseNavigation).Load(); - else - relationEntry.Collection(hasOneAttr.InverseNavigation).Load(); - } - else if (relationshipAttr is HasManyAttribute hasManyAttr && !(relationshipAttr is HasManyThroughAttribute)) + protected void FlushFromCache(IIdentifiable resource) + { + resource = (IIdentifiable) _dbContext.GetTrackedIdentifiable(resource); + if (resource != null) { - foreach (IIdentifiable relationshipValue in (IEnumerable)trackedRelationshipValue) - _dbContext.Entry(relationshipValue).Reference(hasManyAttr.InverseNavigation).Load(); + DetachEntities(new[] {resource}); + DetachRelationships(resource); } } - private bool IsHasOneRelationship(string internalRelationshipName, Type type) + private void DetachEntities(IEnumerable entities) { - var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.Property.Name == internalRelationshipName); - if (relationshipAttr != null) + foreach (var entity in entities) { - if (relationshipAttr is HasOneAttribute) - return true; - - return false; + _dbContext.Entry(entity).State = EntityState.Detached; } - // 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)); } - private void DetachRelationships(TResource resource) + private void DetachRelationships(IIdentifiable resource) { foreach (var relationship in _targetedFields.Relationships) { - var value = relationship.GetValue(resource); - if (value == null) - continue; + var rightValue = relationship.GetValue(resource); + var rightResources = TypeHelper.ExtractResources(rightValue); - 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; - } + DetachEntities(rightResources); } } /// - public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource) + public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, + FilterExpression joinTableFilter) { - _traceWriter.LogMethodStart(new {requestResource, databaseResource}); - if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); - if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); + _traceWriter.LogMethodStart(new {primaryId, secondaryResourceIds}); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); - foreach (var attribute in _targetedFields.Attributes) - attribute.SetValue(databaseResource, attribute.GetValue(requestResource)); + var relationship = _targetedFields.Relationships.Single(); - foreach (var relationshipAttr in _targetedFields.Relationships) + if (relationship is HasManyThroughAttribute hasManyThroughRelationship && joinTableFilter != null) { - // 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); + // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a unique constraint violation. + // We avoid that by excluding already-existing entries from the set in advance. + await ExcludeExistingResourcesFromJoinTableAsync(hasManyThroughRelationship, secondaryResourceIds, joinTableFilter); } - await _dbContext.SaveChangesAsync(); + if (secondaryResourceIds.Any()) + { + var primaryResource = (TResource) _dbContext.GetTrackedOrAttach(CreateResourceWithAssignedId(primaryId)); + + await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIds); + await SaveChangesAsync(); + + // TODO: @ThisPR Do we need to flush cache here? + } } - /// - /// 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. - /// - private object GetTrackedRelationshipValue(RelationshipAttribute relationshipAttr, TResource resource, out bool wasAlreadyAttached) + private async Task ExcludeExistingResourcesFromJoinTableAsync(HasManyThroughAttribute relationship, + ISet secondaryResourceIds, FilterExpression joinTableFilter) { - wasAlreadyAttached = false; - if (relationshipAttr is HasOneAttribute hasOneAttr) - { - var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(resource); - if (relationshipValue == null) - return null; - return GetTrackedHasOneRelationshipValue(relationshipValue, ref wasAlreadyAttached); - } + dynamic query = CreateJoinTableQuery(relationship, joinTableFilter); + IEnumerable joinTableEntities = await EntityFrameworkQueryableExtensions.ToListAsync(query); - IEnumerable relationshipValues = (IEnumerable)relationshipAttr.GetValue(resource); - if (relationshipValues == null) - return null; + RemoveEntitiesFromSet(joinTableEntities, secondaryResourceIds, relationship); + + DetachEntities(joinTableEntities.Cast()); + } - return GetTrackedManyRelationshipValue(relationshipValues, relationshipAttr, ref wasAlreadyAttached); + private IQueryable CreateJoinTableQuery(HasManyThroughAttribute relationship, FilterExpression joinTableFilter) + { + IQueryable throughEntitySet = _dbContext.Set(relationship.ThroughType); + + var scopeFactory = new LambdaScopeFactory(new LambdaParameterNameFactory()); + using var scope = scopeFactory.CreateScope(relationship.ThroughType); + + var whereClauseBuilder = new WhereClauseBuilder(throughEntitySet.Expression, scope, typeof(Queryable)); + + var query = whereClauseBuilder.ApplyWhere(joinTableFilter); + return throughEntitySet.Provider.CreateQuery(query); } - // helper method used in GetTrackedRelationshipValue. See comments below. - private IEnumerable GetTrackedManyRelationshipValue(IEnumerable relationshipValues, RelationshipAttribute relationshipAttr, ref bool wasAlreadyAttached) + private void RemoveEntitiesFromSet(IEnumerable joinTableEntities, ISet secondaryResourceIds, + HasManyThroughAttribute relationship) { - if (relationshipValues == null) return null; - bool newWasAlreadyAttached = false; - - var trackedPointerCollection = TypeHelper.CopyToTypedCollection(relationshipValues.Select(pointer => + HashSet resourcesToExclude = new HashSet(IdentifiableComparer.Instance); + + foreach (var joinTableEntity in joinTableEntities) { - var tracked = AttachOrGetTracked(pointer); - if (tracked != null) newWasAlreadyAttached = true; - - 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); + var resourceToExclude = _resourceFactory.CreateInstance(relationship.RightType); + resourceToExclude.StringId = relationship.RightIdProperty.GetValue(joinTableEntity)?.ToString(); + + resourcesToExclude.Add(resourceToExclude); + } + + secondaryResourceIds.ExceptWith(resourcesToExclude); + } + + private TResource CreateResourceWithAssignedId(TId id) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; - if (newWasAlreadyAttached) wasAlreadyAttached = true; - - return trackedPointerCollection; + return resource; } - // helper method used in GetTrackedRelationshipValue. See comments there. - private IIdentifiable GetTrackedHasOneRelationshipValue(IIdentifiable relationshipValue, ref bool wasAlreadyAttached) + /// + public virtual async Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds) { - var tracked = AttachOrGetTracked(relationshipValue); - if (tracked != null) wasAlreadyAttached = true; - return tracked ?? relationshipValue; + _traceWriter.LogMethodStart(new {primaryResource, secondaryResourceIds}); + + var relationship = _targetedFields.Relationships.Single(); + + await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIds); + await SaveChangesAsync(); + + // TODO: @ThisPR Do we need to flush cache here? } /// - public async Task UpdateRelationshipAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) + public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase) { - _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)); + _traceWriter.LogMethodStart(new {resourceFromRequest, resourceFromDatabase}); + if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); + if (resourceFromDatabase == null) throw new ArgumentNullException(nameof(resourceFromDatabase)); - var typeToUpdate = relationship is HasManyThroughAttribute hasManyThrough - ? hasManyThrough.ThroughType - : relationship.RightType; + foreach (var relationship in _targetedFields.Relationships) + { + var rightResources = relationship.GetValue(resourceFromRequest); + await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightResources); + } + + foreach (var attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); + } - var helper = _genericServiceFactory.Get(typeof(RepositoryRelationshipUpdateHelper<>), typeToUpdate); - await helper.UpdateRelationshipAsync((IIdentifiable)parent, relationship, relationshipIds); + await SaveChangesAsync(); - await _dbContext.SaveChangesAsync(); + FlushFromCache(resourceFromDatabase); } /// - public virtual async Task DeleteAsync(TId id) + public virtual async Task DeleteAsync(TId id) { _traceWriter.LogMethodStart(new {id}); - var resourceToDelete = _resourceFactory.CreateInstance(); - resourceToDelete.Id = id; + var resource = (TResource) _dbContext.GetTrackedOrAttach(CreateResourceWithAssignedId(id)); - var resourceFromCache = _dbContext.GetTrackedEntity(resourceToDelete); - if (resourceFromCache != null) + foreach (var relationship in _resourceGraph.GetRelationships()) { - resourceToDelete = resourceFromCache; + // Loads the data of the relationship if in EF Core it is configured in such a way that loading the related + // entities into memory is required for successfully executing the selected deletion behavior. + if (RequiresLoadOfRelationshipForDeletion(relationship)) + { + var navigation = GetNavigationEntry(resource, relationship); + await navigation.LoadAsync(); + } } - else + + _dbContext.Remove(resource); + await SaveChangesAsync(); + + // TODO: @ThisPR Do we need to flush cache here? + } + + private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) + { + EntityEntry entityEntry = _dbContext.Entry(resource); + + switch (relationship) { - _dbContext.Attach(resourceToDelete); + case HasOneAttribute hasOneRelationship: + { + return entityEntry.Reference(hasOneRelationship.Property.Name); + } + case HasManyAttribute hasManyRelationship: + { + return entityEntry.Collection(hasManyRelationship.Property.Name); + } + default: + { + throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'."); + } } + } - _dbContext.Remove(resourceToDelete); + private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) + { + var navigation = TryGetNavigationForRelationship(relationship); + bool isClearForeignKeyRequired = navigation?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; + + bool isHasOneWithForeignKeyAtLeftSide = IsHasOneWithForeignKeyAtLeftSide(relationship); + + return !isHasOneWithForeignKeyAtLeftSide && isClearForeignKeyRequired; + } + private INavigation TryGetNavigationForRelationship(RelationshipAttribute relationship) + { + return _dbContext.Model.FindEntityType(typeof(TResource)).FindNavigation(relationship.Property.Name); + } + + /// + public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds) + { + _traceWriter.LogMethodStart(new {primaryResource, secondaryResourceIds}); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); + + var relationship = (HasManyAttribute) _targetedFields.Relationships.Single(); + + var rightValue = relationship.GetValue(primaryResource); + var rightResources = ((IEnumerable) rightValue).ToHashSet(IdentifiableComparer.Instance); + rightResources.ExceptWith(secondaryResourceIds); + + await UpdateRelationshipAsync(relationship, primaryResource, rightResources); + await SaveChangesAsync(); + + // TODO: @ThisPR Do we need to flush cache here? + } + + /// + public virtual async Task GetForUpdateAsync(QueryLayer queryLayer) + { + var resources = await GetAsync(queryLayer); + return resources.FirstOrDefault(); + } + + protected virtual async Task SaveChangesAsync() + { try { await _dbContext.SaveChangesAsync(); - return true; } - catch (DbUpdateConcurrencyException) + catch (DbUpdateException exception) { - return false; + throw new DataStoreUpdateException(exception); } } - /// - public virtual void FlushFromCache(TResource resource) + private async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) { - _traceWriter.LogMethodStart(new {resource}); - if (resource == null) throw new ArgumentNullException(nameof(resource)); + var trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + + if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) + { + var entityEntry = _dbContext.Entry(trackedValueToAssign); + var inversePropertyName = relationship.InverseNavigationProperty.Name; + + await entityEntry.Reference(inversePropertyName).LoadAsync(); + } + + if (IsHasOneWithForeignKeyAtLeftSide(relationship) && trackedValueToAssign == null) + { + PrepareChangeTrackerForNullAssignment(relationship, leftResource); + } - _dbContext.Entry(resource).State = EntityState.Detached; + relationship.SetValue(leftResource, trackedValueToAssign); } - /// - /// 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. - /// - /// 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 - /// `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) + private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType) { - if (oldResource == null) throw new ArgumentNullException(nameof(oldResource)); - if (relationshipAttribute == null) throw new ArgumentNullException(nameof(relationshipAttribute)); + if (rightValue == null) + { + return null; + } - if (relationshipAttribute is HasManyThroughAttribute throughAttribute) + var rightResources = TypeHelper.ExtractResources(rightValue); + var rightResourcesTracked = rightResources.Select(resource => _dbContext.GetTrackedOrAttach(resource)).ToArray(); + + return rightValue is IEnumerable + ? (object) TypeHelper.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) + : rightResourcesTracked.Single(); + } + + private static bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, object trackedValueToAssign) + { + // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + return trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship); + } + + private static bool IsOneToOneRelationship(RelationshipAttribute relationship) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + var elementType = TypeHelper.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); + return elementType == null; + } + + return false; + } + + private bool IsHasOneWithForeignKeyAtLeftSide(RelationshipAttribute relationship) + { + if (relationship is HasOneAttribute) { - _dbContext.Entry(oldResource).Collection(throughAttribute.ThroughProperty.Name).Load(); + var navigation = TryGetNavigationForRelationship(relationship); + return navigation != null && navigation.IsDependentToPrincipal(); } - else if (relationshipAttribute is HasManyAttribute hasManyAttribute) + + return false; + } + + private void PrepareChangeTrackerForNullAssignment(RelationshipAttribute relationship, TResource leftResource) + { + // If a (shadow) foreign key is already loaded on the left resource of a relationship, it is not possible to + // set it to null by just assigning null to the navigation property and marking it as modified. + // Instead, when marking it as modified, it will mark the pre-existing foreign key value as modified too, + // but without setting its value to null. + // One way to work around this is by loading the relationship before setting it to null. Another approach + // (as done here) is tricking the change tracker into recognizing the null assignment by first + // assigning a placeholder entity to the navigation property, and then setting it to null. + + var placeholderRightResource = _resourceFactory.CreateInstance(relationship.RightType); + + // When assigning a related entity to a navigation property, it will be attached to the change tracker. + // This fails when that entity has null reference(s) for its primary key. + 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) { - _dbContext.Entry(oldResource).Collection(hasManyAttribute.Property.Name).Load(); + foreach (var property in primaryKey.Properties) + { + var propertyValue = GetNonNullValueForProperty(property.PropertyInfo); + property.PropertyInfo.SetValue(entity, propertyValue); + } } } - /// - /// 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 static object GetNonNullValueForProperty(PropertyInfo propertyInfo) { - var trackedEntity = _dbContext.GetTrackedEntity(relationshipValue); + var propertyType = propertyInfo.PropertyType; + + if (propertyType == typeof(string)) + { + return string.Empty; + } + + if (Nullable.GetUnderlyingType(propertyType) != null) + { + // TODO: @ThisPR Write test with primary key property type int? or equivalent. + propertyType = propertyInfo.PropertyType.GetGenericArguments()[0]; + } - if (trackedEntity != null) + if (propertyType.IsValueType) { - // 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; + return Activator.CreateInstance(propertyType); } - // 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; + throw new InvalidOperationException( + $"Unexpected reference type '{propertyType.Name}' for primary key property '{propertyInfo.DeclaringType?.Name}.{propertyInfo.Name}'."); } } @@ -411,14 +504,14 @@ public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepos where TResource : class, IIdentifiable { public EntityFrameworkCoreRepository( - ITargetedFields targetedFields, - IDbContextResolver contextResolver, + ITargetedFields targetedFields, + IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) - { } + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { + } } } 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..9400612d82 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -2,11 +2,15 @@ namespace JsonApiDotNetCore.Repositories { - /// + /// + /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. + /// + /// The resource type. public interface IResourceRepository - : 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. @@ -14,8 +18,8 @@ public interface IResourceRepository /// The resource type. /// The resource identifier type. public interface IResourceRepository - : IResourceReadRepository, - IResourceWriteRepository + : 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..5cc774c23b 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,12 +1,13 @@ using System.Collections.Generic; using System.Threading.Tasks; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Repositories { /// - public interface IResourceWriteRepository + public interface IResourceWriteRepository : IResourceWriteRepository where TResource : class, IIdentifiable { } @@ -16,7 +17,7 @@ public interface IResourceWriteRepository /// /// The resource type. /// The resource identifier type. - public interface IResourceWriteRepository + public interface IResourceWriteRepository where TResource : class, IIdentifiable { /// @@ -25,27 +26,33 @@ 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 primaryId, ISet secondaryResourceIds, FilterExpression joinTableFilter); /// - /// 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 resourceFromRequest, TResource resourceFromDatabase); /// - /// 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(TResource primaryResource, object secondaryResourceIds); + + /// + /// Deletes an existing resource from the underlying data store. + /// + Task DeleteAsync(TId id); + + /// + /// Removes resources from a to-many relationship in the underlying data store. + /// + Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds); /// - /// Ensures that the next time this resource is requested, it is re-fetched from the underlying data store. + /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for update. /// - void FlushFromCache(TResource resource); + Task GetForUpdateAsync(QueryLayer queryLayer); } } diff --git a/src/JsonApiDotNetCore/Repositories/ISecondaryResourceResolver.cs b/src/JsonApiDotNetCore/Repositories/ISecondaryResourceResolver.cs new file mode 100644 index 0000000000..7468f6a091 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/ISecondaryResourceResolver.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Provides methods to retrieve unassigned related resources using its matching repository. + /// + public interface ISecondaryResourceResolver + { + Task> GetMissingResourcesToAssignInRelationships(IIdentifiable leftResource); + Task> GetMissingSecondaryResources(RelationshipAttribute relationship, ICollection rightResourceIds); + } +} diff --git a/src/JsonApiDotNetCore/Repositories/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..04263c56af --- /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 = GetReadRepository(resourceType); + return (IReadOnlyCollection) await repository.GetAsync(layer); + } + + protected object GetReadRepository(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/Repositories/SecondaryResourceResolver.cs b/src/JsonApiDotNetCore/Repositories/SecondaryResourceResolver.cs new file mode 100644 index 0000000000..a415c9ca07 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/SecondaryResourceResolver.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Repositories +{ + internal sealed class SecondaryResourceResolver : ISecondaryResourceResolver + { + private readonly IResourceContextProvider _resourceContextProvider; + private readonly ITargetedFields _targetedFields; + private readonly IQueryLayerComposer _queryLayerComposer; + private readonly IResourceRepositoryAccessor _resourceRepositoryAccessor; + + public SecondaryResourceResolver(IResourceContextProvider resourceContextProvider, + ITargetedFields targetedFields, IQueryLayerComposer queryLayerComposer, + IResourceRepositoryAccessor resourceRepositoryAccessor) + { + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _queryLayerComposer = queryLayerComposer ?? throw new ArgumentNullException(nameof(queryLayerComposer)); + _resourceRepositoryAccessor = resourceRepositoryAccessor ?? throw new ArgumentNullException(nameof(resourceRepositoryAccessor)); + } + + public async Task> GetMissingResourcesToAssignInRelationships(IIdentifiable leftResource) + { + var missingResources = new List(); + + foreach (var relationship in _targetedFields.Relationships) + { + object rightValue = relationship.GetValue(leftResource); + ICollection rightResources = TypeHelper.ExtractResources(rightValue); + + var missingResourcesInRelationship = GetMissingRightResourcesAsync(rightResources, relationship); + await missingResources.AddRangeAsync(missingResourcesInRelationship); + } + + return missingResources; + } + + public async Task> GetMissingSecondaryResources(RelationshipAttribute relationship, ICollection rightResourceIds) + { + return await GetMissingRightResourcesAsync(rightResourceIds, relationship).ToListAsync(); + } + + private async IAsyncEnumerable GetMissingRightResourcesAsync( + ICollection rightResources, RelationshipAttribute relationship) + { + var rightResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); + var existingResourceIds = await GetExistingResourceIds(rightResources, rightResourceContext); + + foreach (var rightResource in rightResources) + { + if (!existingResourceIds.Contains(rightResource.StringId)) + { + var resourceContext = _resourceContextProvider.GetResourceContext(rightResource.GetType()); + + yield return new MissingResourceInRelationship(relationship.PublicName, + resourceContext.PublicName, rightResource.StringId); + } + } + } + + public async Task> GetExistingResourceIds(ICollection resourceIds, ResourceContext resourceContext) + { + if (!resourceIds.Any()) + { + return Array.Empty(); + } + + var queryLayer = CreateQueryLayerForResourceIds(resourceIds, resourceContext); + + var resources = await _resourceRepositoryAccessor.GetAsync(resourceContext.ResourceType, queryLayer); + return resources.Select(resource => resource.StringId).ToArray(); + } + + private QueryLayer CreateQueryLayerForResourceIds(IEnumerable resourceIds, ResourceContext resourceContext) + { + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + + var typedIds = resourceIds.Select(resource => resource.GetTypedId()).ToArray(); + var filter = _queryLayerComposer.GetFilterOnResourceIds(typedIds, resourceContext); + + return new QueryLayer(resourceContext) + { + Filter = filter, + Projection = new Dictionary + { + [idAttribute] = null + } + }; + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs index 16d8cd1075..76cde56b59 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -91,6 +91,32 @@ public sealed class HasManyThroughAttribute : HasManyAttribute /// public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; + /// + /// Required for a self-referencing many-to-many relationship. + /// Contains the name of the property back to the parent resource from the through type. + /// + public string LeftPropertyName { get; set; } + + /// + /// Required for a self-referencing many-to-many relationship. + /// Contains the name of the property to the related resource from the through type. + /// + public string RightPropertyName { get; set; } + + /// + /// Optional. Can be used to indicate a non-default name for the ID property back to the parent resource from the through type. + /// Defaults to the name of suffixed with "Id". + /// 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 +134,15 @@ public override object GetValue(object resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - IEnumerable throughResources = (IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty(); + var throughEntity = ThroughProperty.GetValue(resource); + if (throughEntity == null) + { + return null; + } - IEnumerable rightResources = throughResources + IEnumerable rightResources = ((IEnumerable) throughEntity) .Cast() - .Select(rightResource => RightProperty.GetValue(rightResource)); + .Select(rightResource => RightProperty.GetValue(rightResource)); return TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); } @@ -121,12 +151,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) { @@ -135,12 +164,13 @@ public override void SetValue(object resource, object newValue, IResourceFactory else { List throughResources = new List(); - foreach (IIdentifiable identifiable in (IEnumerable)newValue) + foreach (IIdentifiable rightResource in (IEnumerable)newValue) { - object throughResource = resourceFactory.CreateInstance(ThroughType); - LeftProperty.SetValue(throughResource, resource); - RightProperty.SetValue(throughResource, identifiable); - throughResources.Add(throughResource); + var throughEntity = TypeHelper.CreateInstance(ThroughType); + + LeftProperty.SetValue(throughEntity, resource); + RightProperty.SetValue(throughEntity, rightResource); + throughResources.Add(throughEntity); } var typedCollection = TypeHelper.CopyToTypedCollection(throughResources, ThroughProperty.PropertyType); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/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..f7cf87099c 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,26 @@ 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 exist. + /// Even if it exists, it 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 +87,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 +97,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/Identifiable.cs b/src/JsonApiDotNetCore/Resources/Identifiable.cs index b621869e80..532e93edcb 100644 --- a/src/JsonApiDotNetCore/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore/Resources/Identifiable.cs @@ -37,7 +37,7 @@ protected virtual string GetStringId(TId value) /// protected virtual TId GetTypedId(string value) { - return string.IsNullOrEmpty(value) ? default : (TId)TypeHelper.ConvertType(value, typeof(TId)); + return value == null ? default : (TId)TypeHelper.ConvertType(value, typeof(TId)); } } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 63c0d6dd46..849fbc807a 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -1,11 +1,12 @@ +using System; using System.Collections.Generic; namespace JsonApiDotNetCore.Resources { /// - /// Compares `IIdentifiable` instances with each other based on StringId. + /// Compares `IIdentifiable` instances with each other based on their type and . /// - internal sealed class IdentifiableComparer : IEqualityComparer + public sealed class IdentifiableComparer : IEqualityComparer { public static readonly IdentifiableComparer Instance = new IdentifiableComparer(); @@ -30,7 +31,7 @@ public bool Equals(IIdentifiable x, IIdentifiable y) public int GetHashCode(IIdentifiable obj) { - return obj.StringId != null ? obj.StringId.GetHashCode() : 0; + return obj.StringId != null ? HashCode.Combine(obj.GetType(), obj.StringId) : 0; } } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs new file mode 100644 index 0000000000..59918a25d9 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Reflection; + +namespace JsonApiDotNetCore.Resources +{ + internal static class IdentifiableExtensions + { + public 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..e0a7a6646e 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) { @@ -32,19 +30,20 @@ public object CreateInstance(Type resourceType) /// 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..2454287034 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,100 +167,109 @@ private IIdentifiable ParseResourceObject(ResourceObject data) return resource; } - /// - /// Sets a HasOne relationship on a parsed resource. If present, also - /// populates the foreign key. - /// - private void SetHasOneRelationship(IIdentifiable resource, - PropertyInfo[] resourceProperties, - HasOneAttribute attr, - RelationshipEntry relationshipData) + private ResourceContext GetExistingResourceContext(string publicName) { - var rio = (ResourceIdentifierObject)relationshipData.Data; - var relatedId = rio?.Id; - - var relationshipType = relationshipData.SingleData == null - ? attr.RightType - : ResourceContextProvider.GetResourceContext(relationshipData.SingleData.Type).ResourceType; + var resourceContext = ResourceContextProvider.GetResourceContext(publicName); + if (resourceContext == null) + { + throw new JsonApiSerializationException("Request body includes unknown resource type.", + $"Resource type '{publicName}' does not exist."); + } - // 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); + return resourceContext; + } - 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); + /// + /// Sets a HasOne relationship on a parsed resource. + /// + private void SetHasOneRelationship(IIdentifiable resource, HasOneAttribute hasOneRelationship, RelationshipEntry relationshipData) + { + if (relationshipData.ManyData != null) + { + throw new JsonApiSerializationException("Expected single data element for to-one relationship.", + $"Expected single data element for '{hasOneRelationship.PublicName}' relationship."); + } - SetNavigation(resource, attr, relatedId, relationshipType); + var rightResource = CreateRightResource(hasOneRelationship, relationshipData.SingleData); + hasOneRelationship.SetValue(resource, rightResource); // depending on if this base parser is used client-side or server-side, // different additional processing per field needs to be executed. - AfterProcessField(resource, attr, relationshipData); + AfterProcessField(resource, hasOneRelationship, relationshipData); } /// - /// Sets the dependent side of a HasOne relationship, which means that a - /// foreign key also will be populated. + /// Sets a HasMany relationship. /// - private void SetForeignKey(IIdentifiable resource, PropertyInfo foreignKey, HasOneAttribute attr, string id, - Type relationshipType) + private void SetHasManyRelationship( + IIdentifiable resource, + HasManyAttribute hasManyRelationship, + RelationshipEntry relationshipData) { - bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKey.PropertyType) != null - || foreignKey.PropertyType == typeof(string); - if (id == null && !foreignKeyPropertyIsNullableType) + if (relationshipData.ManyData == null) { - // 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."); + throw new JsonApiSerializationException("Expected data[] element for to-many relationship.", + $"Expected data[] element for '{hasManyRelationship.PublicName}' relationship."); } - var typedId = TypeHelper.ConvertStringIdToTypedId(relationshipType, id, ResourceFactory); - foreignKey.SetValue(resource, typedId); + var rightResources = relationshipData.ManyData + .Select(rio => CreateRightResource(hasManyRelationship, rio)) + .ToHashSet(IdentifiableComparer.Instance); + + var convertedCollection = TypeHelper.CopyToTypedCollection(rightResources, hasManyRelationship.Property.PropertyType); + hasManyRelationship.SetValue(resource, convertedCollection); + + AfterProcessField(resource, hasManyRelationship, 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, - Type relationshipType) + private IIdentifiable CreateRightResource(RelationshipAttribute relationship, + ResourceIdentifierObject resourceIdentifierObject) { - if (relatedId == null) + if (resourceIdentifierObject != null) { - attr.SetValue(resource, null, ResourceFactory); + AssertHasType(resourceIdentifierObject, relationship); + AssertHasId(resourceIdentifierObject, relationship); + + var rightResourceContext = GetExistingResourceContext(resourceIdentifierObject.Type); + AssertRightTypeIsCompatible(rightResourceContext, relationship); + + var rightInstance = ResourceFactory.CreateInstance(rightResourceContext.ResourceType); + rightInstance.StringId = resourceIdentifierObject.Id; + + return rightInstance; } - else + + return null; + } + + private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) + { + if (resourceIdentifierObject.Type == null) { - var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipType); - relatedInstance.StringId = relatedId; - attr.SetValue(resource, relatedInstance, ResourceFactory); + 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); } } - /// - /// Sets a HasMany relationship. - /// - private void SetHasManyRelationship( - IIdentifiable resource, - HasManyAttribute attr, - RelationshipEntry relationshipData) + private void AssertHasId(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) { - 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 (resourceIdentifierObject.Id == null) + { + throw new JsonApiSerializationException("Request body must include 'id' element.", + $"Expected 'id' element in '{relationship.PublicName}' relationship."); } + } - AfterProcessField(resource, attr, relationshipData); + 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..883329e7f2 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..a958610b58 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; @@ -30,7 +29,7 @@ public virtual ResourceObject Build(IIdentifiable resource, IReadOnlyCollection< var resourceContext = ResourceContextProvider.GetResourceContext(resource.GetType()); // populating the top-level "type" and "id" members. - var resourceObject = new ResourceObject { Type = resourceContext.PublicName, Id = resource.StringId == string.Empty ? null : resource.StringId }; + var resourceObject = new ResourceObject { Type = resourceContext.PublicName, Id = resource.StringId }; // populating the top-level "attribute" member of a resource object. never include "id" as an attribute if (attributes != null && (attributes = attributes.Where(attr => attr.Property.Name != nameof(Identifiable.Id)).ToArray()).Any()) @@ -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..9524e3a0d8 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 _request; public FieldsToSerialize( IResourceGraph resourceGraph, IEnumerable constraintProviders, - IResourceDefinitionAccessor resourceDefinitionAccessor) + IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiRequest request) { _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); + _request = request ?? throw new ArgumentNullException(nameof(request)); } /// @@ -31,6 +35,11 @@ public IReadOnlyCollection GetAttributes(Type resourceType, Relat { if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (_request.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 _request.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..b433b4736a 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,154 +45,176 @@ 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) - { - throw new InvalidRequestBodyException("Payload must include 'id' element.", null, body); - } + return; + } - if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) + var bodyResourceTypes = GetResourceTypesFromRequestBody(model); + foreach (var bodyResourceType in bodyResourceTypes) + { + if (!endpointResourceType.IsAssignableFrom(bodyResourceType)) { - throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); + var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endpointResourceType); + var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); + + throw new ResourceTypeMismatchException(new HttpMethod(httpRequest.Method), + httpRequest.Path, resourceFromEndpoint, resourceFromBody); } } } - /// Checks if the deserialized payload has an ID included - private bool HasMissingId(object model) + private Type GetResourceTypeFromEndpoint() { - return TryGetId(model, out string id) && string.IsNullOrEmpty(id); + return _request.Kind == EndpointKind.Primary + ? _request.PrimaryResource.ResourceType + : _request.SecondaryResource?.ResourceType; } - /// Checks if all elements in the deserialized payload have an ID included - private bool HasMissingId(IEnumerable models) + private IEnumerable GetResourceTypesFromRequestBody(object model) { - foreach (var model in models) + if (model is IEnumerable resourceCollection) { - if (TryGetId(model, out string id) && string.IsNullOrEmpty(id)) - { - return true; - } + return resourceCollection.Select(r => r.GetType()).Distinct(); } - return false; + return model == null ? Array.Empty() : new[] { model.GetType() }; } - private static bool TryGetId(object model, out string id) + private void ValidateRequestIncludesId(object model, string body) { - if (model is ResourceObject resourceObject) + bool hasMissingId = model is IEnumerable list ? HasMissingId(list) : HasMissingId(model); + if (hasMissingId) { - id = resourceObject.Id; - return true; + throw new InvalidRequestBodyException("Request body must include 'id' element.", null, body); } + } - if (model is IIdentifiable identifiable) + private void ValidatePrimaryIdValue(object model, PathString requestPath) + { + if (_request.Kind == EndpointKind.Primary) { - id = identifiable.StringId; - return true; + if (TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) + { + throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, requestPath); + } } - - id = null; - return false; } /// - /// Fetches the request from body asynchronously. + /// Checks if the deserialized request body has an ID included. /// - /// Input stream for body - /// String content of body sent to server. - private async Task GetRequestBody(Stream body) + private bool HasMissingId(object model) { - 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(); + return TryGetId(model, out string id) && id == null; } - - private bool IsPatchOrPostRequest(HttpRequest request) + + /// + /// Checks if all elements in the deserialized request body have an ID included. + /// + private bool HasMissingId(IEnumerable models) { - return request.Method == HttpMethods.Patch || request.Method == HttpMethods.Post; + foreach (var model in models) + { + if (TryGetId(model, out string id) && id == null) + { + return true; + } + } + + return false; } - private IEnumerable GetBodyResourceTypes(object model) + private static bool TryGetId(object model, out string id) { - if (model is IEnumerable resourceCollection) + if (model is IIdentifiable identifiable) { - return resourceCollection.Select(r => r.GetType()).Distinct(); + id = identifiable.StringId; + return true; } - return model == null ? new Type[0] : new[] { model.GetType() }; + id = null; + return false; } - private Type GetEndpointResourceType() + private bool IsPatchRequestForToManyRelationship(string requestMethod) { - 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..d4f420f412 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,7 +1,8 @@ using System; +using System.Linq; 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 +17,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,7 +37,24 @@ public object Deserialize(string body) { if (body == null) throw new ArgumentNullException(nameof(body)); - return DeserializeBody(body); + if (_request.Kind == EndpointKind.Relationship) + { + _targetedFields.Relationships.Add(_request.Relationship); + } + + var instance = DeserializeBody(body); + + AssertResourceIdIsNotTargeted(); + + return instance; + } + + private void AssertResourceIdIsNotTargeted() + { + if (!_request.IsReadOnly && _targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) + { + throw new JsonApiSerializationException("Resource ID is read-only.", null); + } } /// @@ -46,17 +71,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..e816e71f4d 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -22,11 +22,9 @@ 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 + 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 +82,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 +114,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/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs new file mode 100644 index 0000000000..01a37e726e --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -0,0 +1,24 @@ +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 primaryId, 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/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs new file mode 100644 index 0000000000..34c39608e6 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -0,0 +1,24 @@ +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 primaryId, 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..75bfc2722e --- /dev/null +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -0,0 +1,23 @@ +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 primaryId, 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..1f62541909 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -30,7 +30,8 @@ public class JsonApiResourceService : private readonly IJsonApiRequest _request; private readonly IResourceChangeTracker _resourceChangeTracker; private readonly IResourceFactory _resourceFactory; - private readonly IResourceHookExecutor _hookExecutor; + private readonly ISecondaryResourceResolver _secondaryResourceResolver; + private readonly IResourceHookExecutorFacade _hookExecutor; public JsonApiResourceService( IResourceRepository repository, @@ -41,7 +42,8 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) + ISecondaryResourceResolver secondaryResourceResolver, + IResourceHookExecutorFacade hookExecutor) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -53,60 +55,8 @@ 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); - } + _secondaryResourceResolver = secondaryResourceResolver ?? throw new ArgumentNullException(nameof(secondaryResourceResolver)); + _hookExecutor = hookExecutor ?? throw new ArgumentNullException(nameof(hookExecutor)); } /// @@ -114,11 +64,11 @@ public virtual async Task> GetAsync() { _traceWriter.LogMethodStart(); - _hookExecutor?.BeforeRead(ResourcePipeline.Get); + _hookExecutor.BeforeReadMany(); if (_options.IncludeTotalResourceCount) { - var topFilter = _queryLayerComposer.GetTopFilter(); + var topFilter = _queryLayerComposer.GetTopFilterFromConstraints(); _paginationContext.TotalResourceCount = await _repository.CountAsync(topFilter); if (_paginationContext.TotalResourceCount == 0) @@ -127,21 +77,16 @@ public virtual async Task> GetAsync() } } - var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + var queryLayer = _queryLayerComposer.ComposeFromConstraints(_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,34 +94,34 @@ 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 TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting); + AssertPrimaryResourceExists(primaryResource); - 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}); + AssertHasRelationship(_request.Relationship, 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.ComposeFromConstraints(_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/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') } var primaryResources = await _repository.GetAsync(primaryLayer); @@ -184,36 +129,30 @@ private async Task GetPrimaryResourceById(TId id, bool allowTopSparse 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?.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); + AssertHasRelationship(_request.Relationship, relationshipName); - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); - secondaryLayer.Include = null; + _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); + var secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResource); var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); var primaryResources = await _repository.GetAsync(primaryLayer); @@ -221,133 +160,237 @@ public virtual async Task GetRelationshipAsync(TId id, string relatio var primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - if (_hookExecutor != null) - { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); - } + _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetRelationship); - return primaryResource; + var secondaryResourceOrResources = _request.Relationship.GetValue(primaryResource); + + return _hookExecutor.OnReturnRelationship(secondaryResourceOrResources); } /// - // triggered by GET /articles/1/{relationshipName} - public virtual async Task GetSecondaryAsync(TId id, string relationshipName) + public virtual async Task CreateAsync(TResource resource) { - _traceWriter.LogMethodStart(new {id, relationshipName}); - if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); - AssertRelationshipExists(relationshipName); + _resourceChangeTracker.SetRequestedAttributeValues(resource); - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + var defaultResource = _resourceFactory.CreateInstance(); + defaultResource.Id = resource.Id; - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + _resourceChangeTracker.SetInitiallyStoredAttributeValues(defaultResource); - if (_request.IsCollection && _options.IncludeTotalResourceCount) + _hookExecutor.BeforeCreate(resource); + + 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.CreateAsync(resource); } + catch (DataStoreUpdateException) + { + var existingResource = await TryGetPrimaryResourceByIdAsync(resource.Id, TopFieldSelection.OnlyIdAttribute); + if (existingResource != null) + { + throw new ResourceAlreadyExistsException(resource.StringId, _request.PrimaryResource.PublicName); + } - var primaryResources = await _repository.GetAsync(primaryLayer); - - var primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); - - if (_hookExecutor != null) - { - _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetRelationship); - primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single(); + await AssertResourcesToAssignInRelationshipsExistAsync(resource); + throw; } - var secondaryResource = _request.Relationship.GetValue(primaryResource); + var resourceFromDatabase = await TryGetPrimaryResourceByIdAsync(resource.Id, TopFieldSelection.WithAllAttributes); + AssertPrimaryResourceExists(resourceFromDatabase); + + _hookExecutor.AfterCreate(resourceFromDatabase); + + _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); - 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(resourceFromDatabase, ResourcePipeline.Post); + return resourceFromDatabase; + } + + private async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource resource) + { + var missingResources = await _secondaryResourceResolver.GetMissingResourcesToAssignInRelationships(resource); + if (missingResources.Any()) + { + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } } /// - public virtual async Task UpdateAsync(TId id, TResource requestResource) + public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, requestResource}); - if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); + _traceWriter.LogMethodStart(new {primaryId, secondaryResourceIds}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); - TResource databaseResource = await GetPrimaryResourceById(id, false); + AssertHasRelationship(_request.Relationship, relationshipName); + AssertRelationshipIsToMany(_request.Relationship); - _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); - _resourceChangeTracker.SetRequestedAttributeValues(requestResource); + if (secondaryResourceIds.Any()) + { + var joinTableFilter = _request.Relationship is HasManyThroughAttribute hasManyThrough + ? _queryLayerComposer.GetJoinTableFilter(primaryId, + secondaryResourceIds.Select(x => x.GetTypedId()).ToArray(), hasManyThrough) + : null; - if (_hookExecutor != null) + try + { + await _repository.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, joinTableFilter); + } + catch (DataStoreUpdateException) + { + var primaryResource = await TryGetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute); + AssertPrimaryResourceExists(primaryResource); + + await AssertResourcesExistAsync(secondaryResourceIds); + throw; + } + } + } + + private async Task AssertResourcesExistAsync(ICollection secondaryResourceIds) + { + var missingResources = await _secondaryResourceResolver.GetMissingSecondaryResources(_request.Relationship, secondaryResourceIds); + if (missingResources.Any()) { - requestResource = _hookExecutor.BeforeUpdate(AsList(requestResource), ResourcePipeline.Patch).Single(); + throw new ResourcesInRelationshipsNotFoundException(missingResources); } + } - await _repository.UpdateAsync(requestResource, databaseResource); + /// + public virtual async Task UpdateAsync(TId id, TResource resource) + { + _traceWriter.LogMethodStart(new {id, resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (_hookExecutor != null) + var resourceFromRequest = resource; + _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + + _hookExecutor.BeforeUpdateResource(resourceFromRequest); + + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id); + + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); + + try { - _hookExecutor.AfterUpdate(AsList(databaseResource), ResourcePipeline.Patch); - _hookExecutor.OnReturn(AsList(databaseResource), ResourcePipeline.Patch); + await _repository.UpdateAsync(resourceFromRequest, resourceFromDatabase); } + catch (DataStoreUpdateException) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest); + throw; + } + + TResource afterResourceFromDatabase = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes); + AssertPrimaryResourceExists(afterResourceFromDatabase); - _repository.FlushFromCache(databaseResource); - TResource afterResource = await GetPrimaryResourceById(id, false); - _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResource); + _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); + + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? afterResource : null; + if (!hasImplicitChanges) + { + return null; + } + + _hookExecutor.OnReturnSingle(afterResourceFromDatabase, ResourcePipeline.Patch); + return afterResourceFromDatabase; } /// - // triggered by PATCH /articles/1/relationships/{relationshipName} - public virtual async Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships) + public virtual async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds) { - _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + _traceWriter.LogMethodStart(new {primaryId, relationshipName, secondaryResourceIds}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - AssertRelationshipExists(relationshipName); + AssertHasRelationship(_request.Relationship, relationshipName); - var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); - secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); - secondaryLayer.Include = null; + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId); - var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); - primaryLayer.Projection = null; + _hookExecutor.BeforeUpdateRelationshipAsync(resourceFromDatabase); - var primaryResources = await _repository.GetAsync(primaryLayer); + try + { + await _repository.SetRelationshipAsync(resourceFromDatabase, secondaryResourceIds); + } + catch (DataStoreUpdateException) + { + await AssertResourcesExistAsync(TypeHelper.ExtractResources(secondaryResourceIds)); + throw; + } - var primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); + _hookExecutor.AfterUpdateRelationshipAsync(resourceFromDatabase); + } + + /// + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); + + TResource resourceForHooksCached = null; + await _hookExecutor.BeforeDeleteAsync(id, async () => + resourceForHooksCached = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes)); - if (_hookExecutor != null) + try { - primaryResource = _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship).Single(); + await _repository.DeleteAsync(id); } - - string[] relationshipIds = null; - if (relationships != null) + catch (DataStoreUpdateException) { - relationshipIds = _request.Relationship is HasOneAttribute - ? new[] {((IIdentifiable) relationships).StringId} - : ((IEnumerable) relationships).Select(e => e.StringId).ToArray(); + var primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute); + AssertPrimaryResourceExists(primaryResource); + throw; } - await _repository.UpdateRelationshipAsync(primaryResource, _request.Relationship, relationshipIds ?? Array.Empty()); + await _hookExecutor.AfterDeleteAsync(id, () => Task.FromResult(resourceForHooksCached)); + } - if (_hookExecutor != null && primaryResource != null) + /// + public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds) + { + _traceWriter.LogMethodStart(new {primaryId, relationshipName, secondaryResourceIds}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + if (secondaryResourceIds == null) throw new ArgumentNullException(nameof(secondaryResourceIds)); + + AssertHasRelationship(_request.Relationship, relationshipName); + AssertRelationshipIsToMany(_request.Relationship); + + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId); + await AssertResourcesExistAsync(secondaryResourceIds); + + if (secondaryResourceIds.Any()) { - _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); + await _repository.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds); } } + private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection) + { + var primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResource, fieldSelection); + + var primaryResources = await _repository.GetAsync(primaryLayer); + return primaryResources.SingleOrDefault(); + } + + private async Task GetPrimaryResourceForUpdateAsync(TId id) + { + var queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); + var resource = await _repository.GetForUpdateAsync(queryLayer); + + AssertPrimaryResourceExists(resource); + return resource; + } + private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) @@ -356,18 +399,20 @@ private void AssertPrimaryResourceExists(TResource resource) } } - private void AssertRelationshipExists(string relationshipName) + private void AssertHasRelationship(RelationshipAttribute relationship, string name) { - var relationship = _request.Relationship; if (relationship == null) { - throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); + throw new RelationshipNotFoundException(name, _request.PrimaryResource.PublicName); } } - private static List AsList(TResource resource) + private void AssertRelationshipIsToMany(RelationshipAttribute relationship) { - return new List { resource }; + if (!(relationship is HasManyAttribute)) + { + throw new ToManyRelationshipRequiredException(relationship.PublicName); + } } } @@ -388,9 +433,11 @@ public JsonApiResourceService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) + ISecondaryResourceResolver secondaryResourceResolver, + IResourceHookExecutorFacade hookExecutor) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) - { } + resourceChangeTracker, resourceFactory, secondaryResourceResolver, hookExecutor) + { + } } } diff --git a/src/JsonApiDotNetCore/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs index 2a0b802957..0fe351e652 100644 --- a/src/JsonApiDotNetCore/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -92,7 +92,7 @@ public static bool CanContainNull(Type type) return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } - internal static object GetDefaultValue(Type type) + public static object GetDefaultValue(Type type) { return type.IsValueType ? CreateInstance(type) : null; } @@ -266,6 +266,21 @@ public static Type GetIdType(Type resourceType) return property.PropertyType; } + public static ICollection ExtractResources(object value) + { + if (value is IEnumerable resources) + { + return resources.ToList(); + } + + if (value is IIdentifiable resource) + { + return new[] {resource}; + } + + return Array.Empty(); + } + public static object CreateInstance(Type type) { if (type == null) @@ -284,19 +299,6 @@ public static object CreateInstance(Type type) } } - public static object ConvertStringIdToTypedId(Type resourceType, string stringId, IResourceFactory resourceFactory) - { - var tempResource = (IIdentifiable)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); - } - /// /// Extension to use the LINQ cast method in a non-generic way: /// diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 5a7a67c000..337954780f 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); } @@ -153,9 +156,11 @@ public TestModelService( IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) + ISecondaryResourceResolver secondaryResourceResolver, + IResourceHookExecutorFacade hookExecutor) : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceFactory, hookExecutor) + resourceChangeTracker, resourceFactory, secondaryResourceResolver, + hookExecutor) { } } @@ -166,11 +171,10 @@ public TestModelRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) { } } diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index 09174695cd..fe4121469e 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -51,8 +51,8 @@ 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); @@ -88,8 +88,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 repository = new EntityFrameworkCoreRepository(targetedFields.Object, + contextResolverMock.Object, resourceGraph, resourceFactory, new List(), NullLoggerFactory.Instance); + return (repository, targetedFields, resourceGraph); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index 4d6dc63aaa..8ab3b67a0c 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 @@ -200,9 +200,8 @@ public async Task Can_Get_Passports_With_Sparse_Fieldset() public async Task Fail_When_Deleting_Missing_Passport() { // Arrange - string passportId = HexadecimalObfuscationCodec.Encode(1234567890); - var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/passports/" + passportId); + var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/passports/1234567890"); // Act var response = await _fixture.Client.SendAsync(request); @@ -215,7 +214,7 @@ public async Task Fail_When_Deleting_Missing_Passport() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'passports' with ID '" + passportId + "' does not exist.", errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'passports' with ID '1234567890' does not exist.", errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/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 deleted file mode 100644 index eda2dcdd4d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ /dev/null @@ -1,505 +0,0 @@ -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 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 - { - private readonly TestFixture _fixture; - - private readonly Faker _authorFaker; - private readonly Faker
_articleFaker; - private readonly Faker _tagFaker; - - public ManyToManyTests(TestFixture fixture) - { - _fixture = fixture; - var context = _fixture.GetRequiredService(); - - _authorFaker = new Faker() - .RuleFor(a => a.LastName, f => f.Random.Words(2)); - - _articleFaker = new Faker
() - .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) - .RuleFor(a => a.Author, f => _authorFaker.Generate()); - - _tagFaker = new Faker() - .CustomInstantiator(f => new Tag()) - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); - } - - [Fact] - public async Task Can_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() - { - // Arrange - var context = _fixture.GetRequiredService(); - var firstTag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - var articleTag = new ArticleTag - { - Article = article, - Tag = firstTag - }; - 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 = 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("ArticleTags.Tag") - .SingleOrDefaultAsync(a => a.Id == article.Id); - var tag = persistedArticle.ArticleTags.Select(at => at.Tag).Single(); - Assert.Equal(secondTag.Id, tag.Id); - } - - [Fact] - public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap() - { - // Arrange - var context = _fixture.GetRequiredService(); - var firstTag = _tagFaker.Generate(); - var article = _articleFaker.Generate(); - var articleTag = new ArticleTag - { - Article = article, - Tag = firstTag - }; - 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 - { - 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}"); - - _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); - } - } -} 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/DocumentTests/LinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs index 25bc0d8a80..6b51a357fd 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs @@ -15,31 +15,6 @@ public LinksWithNamespaceTests(StandardApplicationFactory factory) : base(factor { } - [Fact] - public async Task GET_RelativeLinks_True_With_Namespace_Returns_RelativeLinks() - { - // Arrange - var person = new Person(); - - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - - var route = "/api/v1/people/" + person.StringId; - var request = new HttpRequestMessage(HttpMethod.Get, route); - - var options = (JsonApiOptions) _factory.GetRequiredService(); - options.UseRelativeLinks = true; - - // Act - var response = await _factory.Client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("/api/v1/people/" + person.StringId, document.Links.Self); - } - [Fact] public async Task GET_RelativeLinks_False_With_Namespace_Returns_AbsoluteLinks() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs deleted file mode 100644 index d80be3aba0..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class FetchingDataTests - { - private readonly TestFixture _fixture; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public FetchingDataTests(TestFixture fixture) - { - _fixture = fixture; - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - } - - [Fact] - public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() - { - // Arrange - var context = _fixture.GetRequiredService(); - await context.ClearTableAsync(); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var result = _fixture.GetDeserializer().DeserializeMany(body); - var items = result.Data; - var meta = result.Meta; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(HeaderConstants.MediaType, response.Content.Headers.ContentType.ToString()); - Assert.Empty(items); - Assert.Equal(0, int.Parse(meta["totalResources"].ToString())); - context.Dispose(); - } - - [Fact] - public async Task Included_Resources_Contain_Relationship_Links() - { - // Arrange - var context = _fixture.GetRequiredService(); - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - todoItem.Owner = person; - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonConvert.DeserializeObject(body); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(person.StringId, deserializedBody.Included[0].Id); - Assert.NotNull(deserializedBody.Included[0].Relationships); - Assert.Equal($"http://localhost/api/v1/people/{person.Id}/todoItems", deserializedBody.Included[0].Relationships["todoItems"].Links.Related); - Assert.Equal($"http://localhost/api/v1/people/{person.Id}/relationships/todoItems", deserializedBody.Included[0].Relationships["todoItems"].Links.Self); - context.Dispose(); - } - - [Fact] - public async Task GetResources_NoDefaultPageSize_ReturnsResources() - { - // Arrange - var context = _fixture.GetRequiredService(); - await context.ClearTableAsync(); - await context.SaveChangesAsync(); - - var todoItems = _todoItemFaker.Generate(20); - context.TodoItems.AddRange(todoItems); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var server = new TestServer(builder); - - var options = (JsonApiOptions)server.Services.GetRequiredService(); - options.DefaultPageSize = null; - - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var result = _fixture.GetDeserializer().DeserializeMany(body); - - // Assert - Assert.True(result.Data.Count == 20); - } - - [Fact] - public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() - { - // Arrange - var context = _fixture.GetRequiredService(); - await context.ClearTableAsync(); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems/123"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '123' does not exist.", errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs deleted file mode 100644 index 3c65dec700..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ /dev/null @@ -1,371 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Extensions; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class FetchingRelationshipsTests - { - private readonly TestFixture _fixture; - private readonly Faker _todoItemFaker; - - public FetchingRelationshipsTests(TestFixture fixture) - { - _fixture = fixture; - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - } - - [Fact] - public async Task When_getting_existing_ToOne_relationship_it_should_succeed() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = new Person(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var json = JsonConvert.DeserializeObject(body).ToString(); - - string expected = @"{ - ""links"": { - ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"", - ""related"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" - }, - ""data"": { - ""type"": ""people"", - ""id"": """ + todoItem.Owner.StringId + @""" - } -}"; - Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); - } - - [Fact] - public async Task When_getting_existing_ToMany_relationship_it_should_succeed() - { - // Arrange - var author = new Author - { - LastName = "X", - Articles = new List
- { - new Article - { - Caption = "Y" - }, - new Article - { - Caption = "Z" - } - } - }; - - var context = _fixture.GetRequiredService(); - context.AuthorDifferentDbContextName.Add(author); - await context.SaveChangesAsync(); - - var route = $"/api/v1/authors/{author.Id}/relationships/articles"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var json = JsonConvert.DeserializeObject(body).ToString(); - - var expected = @"{ - ""links"": { - ""self"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"", - ""related"": ""http://localhost/api/v1/authors/" + author.StringId + @"/articles"" - }, - ""data"": [ - { - ""type"": ""articles"", - ""id"": """ + author.Articles[0].StringId + @""" - }, - { - ""type"": ""articles"", - ""id"": """ + author.Articles[1].StringId + @""" - } - ] -}"; - - Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); - } - - [Fact] - public async Task When_getting_related_missing_to_one_resource_it_should_succeed_with_null_data() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = null; - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.StringId}/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var json = JsonConvert.DeserializeObject(body).ToString(); - - var expected = @"{ - ""links"": { - ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" - }, - ""data"": null -}"; - - Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); - } - - [Fact] - public async Task When_getting_relationship_for_missing_to_one_resource_it_should_succeed_with_null_data() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = null; - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var doc = JsonConvert.DeserializeObject(body); - Assert.False(doc.IsManyData); - Assert.Null(doc.Data); - } - - [Fact] - public async Task When_getting_related_missing_to_many_resource_it_should_succeed_with_null_data() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.ChildrenTodos = new List(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/childrenTodos"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var doc = JsonConvert.DeserializeObject(body); - Assert.True(doc.IsManyData); - Assert.Empty(doc.ManyData); - } - - [Fact] - public async Task When_getting_relationship_for_missing_to_many_resource_it_should_succeed_with_null_data() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.ChildrenTodos = new List(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/childrenTodos"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var doc = JsonConvert.DeserializeObject(body); - Assert.True(doc.IsManyData); - Assert.Empty(doc.ManyData); - } - - [Fact] - public async Task When_getting_related_for_missing_parent_resource_it_should_fail() - { - // Arrange - var route = "/api/v1/todoItems/99999999/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task When_getting_relationship_for_missing_parent_resource_it_should_fail() - { - // Arrange - var route = "/api/v1/todoItems/99999999/relationships/owner"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task When_getting_unknown_related_resource_it_should_fail() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/invalid"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task When_getting_unknown_relationship_for_resource_it_should_fail() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - - var context = _fixture.GetRequiredService(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The resource '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 deleted file mode 100644 index 5764b77542..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ /dev/null @@ -1,850 +0,0 @@ -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 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 - { - private readonly TestFixture _fixture; - private AppDbContext _context; - private readonly Faker _personFaker; - private readonly Faker _todoItemFaker; - - 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()); - - _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 Can_Update_Cyclic_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 - { - 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); - } - - [Fact] - public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() - { - // Arrange - 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(); - - // Act - var content = new - { - data = new - { - type = "todoItems", - id = todoItem.Id, - relationships = new Dictionary - { - { "dependentOnTodo", new - { - data = new { type = "todoItems", id = $"{todoItem.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.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 - { - data = new - { - type = "todoItems", - id = todoItem.Id, - relationships = new Dictionary - { - { "dependentOnTodo", new - { - data = new { type = "todoItems", id = $"{todoItem.Id}" } - } - }, - { "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.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[] - { - 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); - - 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); - } - - [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(); - - var content = new - { - 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); - - 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); - } - - [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() - { - // 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 - { - 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); - - // 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 - { - 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", - relationships = new Dictionary - { - { "todoItems", new - { - data = new List() - } - } - } - } - }; - - 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); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var personResult = _context.People - .AsNoTracking() - .Include(p => p.TodoItems) - .Single(p => p.Id == person.Id); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Empty(personResult.TodoItems); - } - - [Fact] - public async Task Can_Delete_Relationship_By_Patching_Relationship() - { - // 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 httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - 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 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 - { - data = new - { - type = "people", - id = person2.Id, - relationships = new Dictionary - { - { "passport", new - { - data = new { type = "passports", id = $"{passport.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 - { - data = new List - { - new { - type = "todoItems", - id = $"{todoItem1Id}" - }, - new { - type = "todoItems", - id = $"{todoItem2Id}" - } - } - } - } - } - } - }; - - 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("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); - - 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); - } - } -} 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 deleted file mode 100644 index 9a75fcbb82..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs +++ /dev/null @@ -1,402 +0,0 @@ -using System; -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.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class TodoItemControllerTests - { - private readonly TestFixture _fixture; - private readonly AppDbContext _context; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public TodoItemControllerTests(TestFixture fixture) - { - _fixture = fixture; - _context = fixture.GetRequiredService(); - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()) - .RuleFor(t => t.Age, f => f.Random.Int(1, 99)); - } - - [Fact] - public async Task Can_Get_TodoItems_Paginate_Check() - { - // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); - var expectedResourcesPerPage = _fixture.GetRequiredService().DefaultPageSize.Value; - var person = new Person(); - var todoItems = _todoItemFaker.Generate(expectedResourcesPerPage + 1); - - foreach (var todoItem in todoItems) - { - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - } - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeMany(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - Assert.True(deserializedBody.Count <= expectedResourcesPerPage, $"There are more items on the page than the default page size. {deserializedBody.Count} > {expectedResourcesPerPage}"); - } - - [Fact] - public async Task Can_Get_TodoItem_ById() - { - // Arrange - var person = new Person(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(todoItem.Id, deserializedBody.Id); - Assert.Equal(todoItem.Description, deserializedBody.Description); - Assert.Equal(todoItem.Ordinal, deserializedBody.Ordinal); - Assert.Equal(todoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Null(deserializedBody.AchievedDate); - } - - [Fact] - public async Task Can_Post_TodoItem() - { - // Arrange - var 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 todoItem = _todoItemFaker.Generate(); - var nowOffset = new DateTimeOffset(); - todoItem.OffsetDate = nowOffset; - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/todoItems"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(serializer.Serialize(todoItem)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal(todoItem.Description, deserializedBody.Description); - Assert.Equal(todoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Equal(nowOffset, deserializedBody.OffsetDate); - Assert.Null(deserializedBody.AchievedDate); - } - - [Fact] - public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() - { - // Arrange - var person1 = new Person(); - var person2 = new Person(); - _context.People.Add(person1); - _context.People.Add(person2); - await _context.SaveChangesAsync(); - - var todoItem = _todoItemFaker.Generate(); - var content = new - { - data = new - { - type = "todoItems", - attributes = new Dictionary - { - { "description", todoItem.Description }, - { "ordinal", todoItem.Ordinal }, - { "createdDate", todoItem.CreatedDate } - }, - relationships = new - { - owner = new - { - data = new - { - type = "people", - id = person1.Id.ToString() - } - }, - assignee = new - { - data = new - { - type = "people", - id = person2.Id.ToString() - } - } - } - } - }; - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/todoItems"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert -- response - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - var resultId = int.Parse(document.SingleData.Id); - - // Assert -- database - var todoItemResult = await _context.TodoItems.SingleAsync(t => t.Id == resultId); - - Assert.Equal(person1.Id, todoItemResult.OwnerId); - Assert.Equal(person2.Id, todoItemResult.AssigneeId); - } - - [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; - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var newTodoItem = _todoItemFaker.Generate(); - - var content = new - { - data = new - { - id = todoItem.Id, - type = "todoItems", - attributes = new Dictionary - { - { "description", newTodoItem.Description }, - { "ordinal", newTodoItem.Ordinal }, - { "alwaysChangingValue", "ignored" }, - { "createdDate", newTodoItem.CreatedDate } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(newTodoItem.Description, deserializedBody.Description); - Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); - Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Null(deserializedBody.AchievedDate); - } - - [Fact] - public async Task Can_Patch_TodoItemWithNullable() - { - // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var newTodoItem = _todoItemFaker.Generate(); - newTodoItem.AchievedDate = new DateTime(2002, 2,4); - - var content = new - { - data = new - { - id = todoItem.Id, - type = "todoItems", - attributes = new Dictionary - { - { "description", newTodoItem.Description }, - { "ordinal", newTodoItem.Ordinal }, - { "createdDate", newTodoItem.CreatedDate }, - { "achievedDate", newTodoItem.AchievedDate } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(newTodoItem.Description, deserializedBody.Description); - Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); - Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Equal(newTodoItem.AchievedDate.GetValueOrDefault().ToString("G"), deserializedBody.AchievedDate.GetValueOrDefault().ToString("G")); - } - - [Fact] - public async Task Can_Patch_TodoItemWithNullValue() - { - // Arrange - var person = new Person(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var newTodoItem = _todoItemFaker.Generate(); - - var content = new - { - data = new - { - id = todoItem.Id, - type = "todoItems", - attributes = new Dictionary - { - { "description", newTodoItem.Description }, - { "ordinal", newTodoItem.Ordinal }, - { "createdDate", newTodoItem.CreatedDate }, - { "achievedDate", null } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(JsonConvert.SerializeObject(content)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(newTodoItem.Description, deserializedBody.Description); - Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); - Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Null(deserializedBody.AchievedDate); - } - - [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/Factories/ClientGeneratedIdsApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs deleted file mode 100644 index 570e479b16..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs +++ /dev/null @@ -1,32 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; - -namespace JsonApiDotNetCoreExampleTests -{ - public class ClientGeneratedIdsApplicationFactory : CustomApplicationFactoryBase - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - base.ConfigureWebHost(builder); - - builder.ConfigureServices(services => - { - services.AddClientSerialization(); - }); - - builder.ConfigureTestServices(services => - { - services.AddJsonApi(options => - { - options.Namespace = "api/v1"; - options.DefaultPageSize = new PageSize(5); - options.IncludeTotalResourceCount = true; - options.AllowClientGeneratedIds = true; - options.IncludeExceptionStackTraceInErrors = true; - }, - discovery => discovery.AddAssembly(typeof(JsonApiDotNetCoreExample.Program).Assembly)); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/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..d266ae4ae1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +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, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, 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..9f20310613 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -0,0 +1,569 @@ +using System.Collections.Generic; +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.CompositeKeys +{ + public sealed class CompositeKeyTests + : IClassFixture, CompositeDbContext>> + { + private readonly IntegrationTestContext, CompositeDbContext> _testContext; + + public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository(); + }); + + 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/FakerContainer.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs new file mode 100644 index 0000000000..50ce77c416 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests +{ + internal abstract class FakerContainer + { + protected static int GetFakerSeed() + { + // The goal here is to have stable data over multiple test runs, but at the same time different data per test case. + + MethodBase testMethod = GetTestMethod(); + var testName = testMethod.DeclaringType?.FullName + "." + testMethod.Name; + + return GetDeterministicHashCode(testName); + } + + private static MethodBase GetTestMethod() + { + var stackTrace = new StackTrace(); + + var testMethod = stackTrace.GetFrames() + .Select(stackFrame => stackFrame?.GetMethod()) + .FirstOrDefault(IsTestMethod); + + if (testMethod == null) + { + // If called after the first await statement, the test method is no longer on the stack, + // but has been replaced with the compiler-generated async/wait state machine. + throw new InvalidOperationException("Fakers can only be used from within (the start of) a test method."); + } + + return testMethod; + } + + private static bool IsTestMethod(MethodBase method) + { + if (method == null) + { + return false; + } + + return method.GetCustomAttribute(typeof(FactAttribute)) != null || method.GetCustomAttribute(typeof(TheoryAttribute)) != null; + } + + private static int GetDeterministicHashCode(string source) + { + // https://andrewlock.net/why-is-string-gethashcode-different-each-time-i-run-my-program-in-net-core/ + unchecked + { + int hash1 = (5381 << 16) + 5381; + int hash2 = hash1; + + for (int i = 0; i < source.Length; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ source[i]; + + if (i == source.Length - 1) + { + break; + } + + hash2 = ((hash2 << 5) + hash2) ^ source[i + 1]; + } + + return hash1 + hash2 * 1566083941; + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs index 43a105b644..6c4703f71d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; @@ -63,7 +62,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)"; @@ -110,87 +109,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Id.Should().Be(person.StringId); responseDocument.ManyData[0].Attributes["firstName"].Should().Be(person.FirstName); } - - [Fact] - public async Task Can_filter_on_obfuscated_ID() - { - // Arrange - Passport passport = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - passport = new Passport(dbContext) - { - SocialSecurityNumber = 123, - BirthCountry = new Country() - }; - - await dbContext.ClearTableAsync(); - dbContext.Passports.AddRange(passport, new Passport(dbContext)); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/passports?filter=equals(id,'{passport.StringId}')"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(passport.StringId); - responseDocument.ManyData[0].Attributes["socialSecurityNumber"].Should().Be(passport.SocialSecurityNumber); - } - - [Fact] - public async Task Can_filter_in_set_on_obfuscated_ID() - { - // Arrange - var passports = new List(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - passports.AddRange(new[] - { - new Passport(dbContext) - { - SocialSecurityNumber = 123, - BirthCountry = new Country() - }, - new Passport(dbContext) - { - SocialSecurityNumber = 456, - BirthCountry = new Country() - }, - new Passport(dbContext) - { - BirthCountry = new Country() - } - }); - - await dbContext.ClearTableAsync(); - dbContext.Passports.AddRange(passports); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/passports?filter=any(id,'{passports[0].StringId}','{passports[1].StringId}')"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - - responseDocument.ManyData[0].Id.Should().Be(passports[0].StringId); - responseDocument.ManyData[0].Attributes["socialSecurityNumber"].Should().Be(passports[0].SocialSecurityNumber); - - responseDocument.ManyData[1].Id.Should().Be(passports[1].StringId); - responseDocument.ManyData[1].Attributes["socialSecurityNumber"].Should().Be(passports[1].SocialSecurityNumber); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs new file mode 100644 index 0000000000..d2f33c7dc7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class BankAccount : ObfuscatedIdentifiable + { + [Attr] + public string Iban { get; set; } + + [HasMany] + public IList Cards { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs new file mode 100644 index 0000000000..91793dfc8c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class BankAccountsController : ObfuscatedIdentifiableController + { + public BankAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs new file mode 100644 index 0000000000..9bd4bcc789 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class DebitCard : ObfuscatedIdentifiable + { + [Attr] + public string OwnerName { get; set; } + + [Attr] + public short PinCode { get; set; } + + [HasOne] + public BankAccount Account { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs new file mode 100644 index 0000000000..b72cea109e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class DebitCardsController : ObfuscatedIdentifiableController + { + public DebitCardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs similarity index 69% rename from src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index 27fa9256c0..96c4642f4f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs @@ -1,22 +1,29 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Net; using System.Text; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCoreExample +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation { - public static class HexadecimalObfuscationCodec + internal static class HexadecimalCodec { public static int Decode(string value) { - if (string.IsNullOrEmpty(value)) + if (value == null) { return 0; } if (!value.StartsWith("x")) { - throw new InvalidOperationException("Invalid obfuscated id."); + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Invalid ID value.", + Detail = $"The value '{value}' is not a valid hexadecimal value." + }); } string stringValue = FromHexString(value.Substring(1)); @@ -37,11 +44,11 @@ private static string FromHexString(string hexString) return new string(chars); } - public static string Encode(object value) + public static string Encode(int value) { - if (value is int intValue && intValue == 0) + if (value == 0) { - return string.Empty; + return null; } string stringValue = value.ToString(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs new file mode 100644 index 0000000000..811f58a7a3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -0,0 +1,476 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class IdObfuscationTests + : IClassFixture, ObfuscationDbContext>> + { + private readonly IntegrationTestContext, ObfuscationDbContext> _testContext; + private readonly ObfuscationFakers _fakers = new ObfuscationFakers(); + + public IdObfuscationTests(IntegrationTestContext, ObfuscationDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_filter_equality_in_primary_resources() + { + // Arrange + var bankAccounts = _fakers.BankAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.BankAccounts.AddRange(bankAccounts); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts?filter=equals(id,'{bankAccounts[1].StringId}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(bankAccounts[1].StringId); + } + + [Fact] + public async Task Can_filter_any_in_primary_resources() + { + // Arrange + var bankAccounts = _fakers.BankAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.BankAccounts.AddRange(bankAccounts); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts?filter=any(id,'{bankAccounts[1].StringId}','{HexadecimalCodec.Encode(99999999)}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(bankAccounts[1].StringId); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_invalid_ID() + { + // Arrange + var route = "/bankAccounts/not-a-hex-value"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Invalid ID value."); + responseDocument.Errors[0].Detail.Should().Be("The value 'not-a-hex-value' is not a valid hexadecimal value."); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var debitCard = _fakers.DebitCard.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DebitCards.Add(debitCard); + await dbContext.SaveChangesAsync(); + }); + + var route = "/debitCards/" + debitCard.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(debitCard.StringId); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + var bankAccount = _fakers.BankAccount.Generate(); + bankAccount.Cards = _fakers.DebitCard.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(bankAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts/{bankAccount.StringId}/cards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(bankAccount.Cards[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(bankAccount.Cards[1].StringId); + } + + [Fact] + public async Task Can_include_resource_with_sparse_fieldset() + { + // Arrange + var bankAccount = _fakers.BankAccount.Generate(); + bankAccount.Cards = _fakers.DebitCard.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(bankAccount); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts/{bankAccount.StringId}?include=cards&fields[cards]=ownerName"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(bankAccount.StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Id.Should().Be(bankAccount.Cards[0].StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + } + + [Fact] + public async Task Can_get_relationship() + { + // Arrange + var bankAccount = _fakers.BankAccount.Generate(); + bankAccount.Cards = _fakers.DebitCard.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(bankAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/bankAccounts/{bankAccount.StringId}/relationships/cards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(bankAccount.Cards[0].StringId); + } + + [Fact] + public async Task Can_create_resource_with_relationship() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + var newDebitCard = _fakers.DebitCard.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(existingBankAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "debitCards", + attributes = new + { + ownerName = newDebitCard.OwnerName, + pinCode = newDebitCard.PinCode + }, + relationships = new + { + account = new + { + data = new + { + type = "bankAccounts", + id = existingBankAccount.StringId + } + } + } + } + }; + + var route = "/debitCards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Attributes["ownerName"].Should().Be(newDebitCard.OwnerName); + responseDocument.SingleData.Attributes["pinCode"].Should().Be(newDebitCard.PinCode); + + var newDebitCardId = HexadecimalCodec.Decode(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var debitCardInDatabase = await dbContext.DebitCards + .Include(debitCard => debitCard.Account) + .FirstAsync(debitCard => debitCard.Id == newDebitCardId); + + debitCardInDatabase.OwnerName.Should().Be(newDebitCard.OwnerName); + debitCardInDatabase.PinCode.Should().Be(newDebitCard.PinCode); + + debitCardInDatabase.Account.Should().NotBeNull(); + debitCardInDatabase.Account.Id.Should().Be(existingBankAccount.Id); + debitCardInDatabase.Account.StringId.Should().Be(existingBankAccount.StringId); + }); + } + + [Fact] + public async Task Can_update_resource_with_relationship() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + existingBankAccount.Cards = _fakers.DebitCard.Generate(1); + + var existingDebitCard = _fakers.DebitCard.Generate(); + + var newIban = _fakers.BankAccount.Generate().Iban; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingBankAccount, existingDebitCard); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "bankAccounts", + id = existingBankAccount.StringId, + attributes = new + { + iban = newIban + }, + relationships = new + { + cards = new + { + data = new[] + { + new + { + type = "debitCards", + id = existingDebitCard.StringId + } + } + } + } + } + }; + + var route = "/bankAccounts/" + existingBankAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var bankAccountInDatabase = await dbContext.BankAccounts + .Include(bankAccount => bankAccount.Cards) + .FirstAsync(bankAccount => bankAccount.Id == existingBankAccount.Id); + + bankAccountInDatabase.Iban.Should().Be(newIban); + + bankAccountInDatabase.Cards.Should().HaveCount(1); + bankAccountInDatabase.Cards[0].Id.Should().Be(existingDebitCard.Id); + bankAccountInDatabase.Cards[0].StringId.Should().Be(existingDebitCard.StringId); + }); + + } + + [Fact] + public async Task Can_add_to_ToMany_relationship() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + existingBankAccount.Cards = _fakers.DebitCard.Generate(1); + + var existingDebitCard = _fakers.DebitCard.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingBankAccount, existingDebitCard); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "debitCards", + id = existingDebitCard.StringId + } + } + }; + + var route = $"/bankAccounts/{existingBankAccount.StringId}/relationships/cards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var bankAccountInDatabase = await dbContext.BankAccounts + .Include(bankAccount => bankAccount.Cards) + .FirstAsync(bankAccount => bankAccount.Id == existingBankAccount.Id); + + bankAccountInDatabase.Cards.Should().HaveCount(2); + }); + } + + [Fact] + public async Task Can_remove_from_ToMany_relationship() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + existingBankAccount.Cards = _fakers.DebitCard.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(existingBankAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "debitCards", + id = existingBankAccount.Cards[0].StringId + } + } + }; + + var route = $"/bankAccounts/{existingBankAccount.StringId}/relationships/cards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var bankAccountInDatabase = await dbContext.BankAccounts + .Include(bankAccount => bankAccount.Cards) + .FirstAsync(bankAccount => bankAccount.Id == existingBankAccount.Id); + + bankAccountInDatabase.Cards.Should().HaveCount(1); + }); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var existingBankAccount = _fakers.BankAccount.Generate(); + existingBankAccount.Cards = _fakers.DebitCard.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(existingBankAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = "/bankAccounts/" + existingBankAccount.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var bankAccountInDatabase = await dbContext.BankAccounts + .Include(bankAccount => bankAccount.Cards) + .FirstOrDefaultAsync(bankAccount => bankAccount.Id == existingBankAccount.Id); + + bankAccountInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_missing_resource() + { + // Arrange + var stringId = HexadecimalCodec.Encode(99999999); + + var route = "/bankAccounts/" + stringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'bankAccounts' with ID '{stringId}' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs new file mode 100644 index 0000000000..ffe9baa52a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public abstract class ObfuscatedIdentifiable : Identifiable + { + protected override string GetStringId(int value) + { + return HexadecimalCodec.Encode(value); + } + + protected override int GetTypedId(string value) + { + return HexadecimalCodec.Decode(value); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs new file mode 100644 index 0000000000..5ce3179878 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public abstract class ObfuscatedIdentifiableController : BaseJsonApiController + where TResource : class, IIdentifiable + { + protected ObfuscatedIdentifiableController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + [HttpGet] + public override Task GetAsync() + { + return base.GetAsync(); + } + + [HttpGet("{id}")] + public Task GetAsync(string id) + { + int idValue = HexadecimalCodec.Decode(id); + return base.GetAsync(idValue); + } + + [HttpGet("{id}/{relationshipName}")] + public Task GetSecondaryAsync(string id, string relationshipName) + { + int idValue = HexadecimalCodec.Decode(id); + return base.GetSecondaryAsync(idValue, relationshipName); + } + + [HttpGet("{id}/relationships/{relationshipName}")] + public Task GetRelationshipAsync(string id, string relationshipName) + { + int idValue = HexadecimalCodec.Decode(id); + return base.GetRelationshipAsync(idValue, relationshipName); + } + + [HttpPost] + public override Task PostAsync([FromBody] TResource resource) + { + return base.PostAsync(resource); + } + + [HttpPost("{id}/relationships/{relationshipName}")] + public Task PostRelationshipAsync(string id, string relationshipName, + [FromBody] ISet secondaryResourceIds) + { + int idValue = HexadecimalCodec.Decode(id); + return base.PostRelationshipAsync(idValue, relationshipName, secondaryResourceIds); + } + + [HttpPatch("{id}")] + public Task PatchAsync(string id, [FromBody] TResource resource) + { + int idValue = HexadecimalCodec.Decode(id); + return base.PatchAsync(idValue, resource); + } + + [HttpPatch("{id}/relationships/{relationshipName}")] + public Task PatchRelationshipAsync(string id, string relationshipName, + [FromBody] object secondaryResourceIds) + { + int idValue = HexadecimalCodec.Decode(id); + return base.PatchRelationshipAsync(idValue, relationshipName, secondaryResourceIds); + } + + [HttpDelete("{id}")] + public Task DeleteAsync(string id) + { + int idValue = HexadecimalCodec.Decode(id); + return base.DeleteAsync(idValue); + } + + [HttpDelete("{id}/relationships/{relationshipName}")] + public Task DeleteRelationshipAsync(string id, string relationshipName, + [FromBody] ISet secondaryResourceIds) + { + int idValue = HexadecimalCodec.Decode(id); + return base.DeleteRelationshipAsync(idValue, relationshipName, secondaryResourceIds); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs new file mode 100644 index 0000000000..489ba4970c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + public sealed class ObfuscationDbContext : DbContext + { + public DbSet BankAccounts { get; set; } + public DbSet DebitCards { get; set; } + + public ObfuscationDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs new file mode 100644 index 0000000000..4d31f063da --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs @@ -0,0 +1,22 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation +{ + internal sealed class ObfuscationFakers : FakerContainer + { + private readonly Lazy> _lazyBankAccountFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(bankAccount => bankAccount.Iban, f => f.Finance.Iban())); + + private readonly Lazy> _lazyDebitCardFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(debitCard => debitCard.OwnerName, f => f.Name.FullName()) + .RuleFor(debitCard => debitCard.PinCode, f => (short)f.Random.Number(1000, 9999))); + + public Faker BankAccount => _lazyBankAccountFaker.Value; + public Faker DebitCard => _lazyDebitCardFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index 768b98ae16..00a281ff6f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -765,7 +765,7 @@ public async Task Cannot_include_unknown_nested_relationship() } [Fact] - public async Task Cannot_include_blocked_relationship() + public async Task Cannot_include_relationship_with_blocked_capability() { // Arrange var route = "/api/v1/people?include=unIncludeableItem"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs new file mode 100644 index 0000000000..93c8630303 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class RelativeLinksWithNamespaceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public RelativeLinksWithNamespaceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.Namespace = "api/v1"; + options.UseRelativeLinks = true; + options.DefaultPageSize = new PageSize(10); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Get_primary_resource_by_ID_returns_links() + { + // Arrange + var person = new Person(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/people/" + person.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + + responseDocument.SingleData.Relationships["todoItems"].Links.Self.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); + responseDocument.SingleData.Relationships["todoItems"].Links.Related.Should().Be($"/api/v1/people/{person.StringId}/todoItems"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_links() + { + // Arrange + var person = new Person + { + TodoItems = new HashSet + { + new TodoItem() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/people?include=todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.First.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.Last.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/api/v1/todoItems/{person.TodoItems.ElementAt(0).StringId}"); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_links() + { + // Arrange + var person = new Person + { + TodoItems = new HashSet + { + new TodoItem() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // TODO: @ThisPR links/related was removed from the expected response body here, which violates the json:api spec. + + responseDocument.Links.Self.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); + responseDocument.Links.First.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/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..326e348ea9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation @@ -21,19 +20,18 @@ public ModelStateValidationTests(IntegrationTestContext + attributes = new { - ["isCaseSensitive"] = "true" + isCaseSensitive = true } } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -53,20 +51,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 +83,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 +115,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 +145,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 +213,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 +236,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["files"] = new + files = new { data = new[] { @@ -253,7 +247,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["parent"] = new + parent = new { data = new { @@ -265,7 +259,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories"; // Act @@ -279,6 +272,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 +333,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 +373,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 +417,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 +461,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 +488,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string requestBody = JsonConvert.SerializeObject(content); string route = "/systemDirectories/-1"; // Act @@ -491,29 +525,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 +604,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 +627,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["files"] = new + files = new { data = new[] { @@ -605,7 +638,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }, - ["parent"] = new + parent = new { data = new { @@ -617,16 +650,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 +677,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 +697,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = directory.StringId } }, - ["alsoSelf"] = new + alsoSelf = new { data = new { @@ -677,16 +709,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 +736,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 +763,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 +801,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new { @@ -780,16 +810,15 @@ 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.Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); } [Fact] @@ -826,7 +855,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var content = new + var requestBody = new { data = new[] { @@ -838,16 +867,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/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs new file mode 100644 index 0000000000..ca90bf960e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -0,0 +1,28 @@ +using System; +using FluentAssertions; +using FluentAssertions.Primitives; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests +{ + public static class ObjectAssertionsExtensions + { + /// + /// Used to assert on a nullable column, whose value is returned as in json:api response body. + /// + public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expected, string because = "", + params object[] becauseArgs) + { + if (expected == null) + { + source.Subject.Should().BeNull(because, becauseArgs); + } + else + { + // We lose a little bit of precision (milliseconds) on roundtrip through PostgreSQL database. + + var value = new DateTimeOffset((DateTime) source.Subject); + value.Should().BeCloseTo(expected.Value, because: because, becauseArgs: becauseArgs); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs index cc695d45b9..a925389515 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -557,6 +557,51 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Next.Should().Be($"http://localhost/api/v1/blogs/{blog.StringId}/articles?page[number]=2"); } + [Fact] + public async Task Returns_all_resources_when_paging_is_disabled() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = null; + + var blog = new Blog + { + Articles = new List
() + }; + + for (int index = 0; index < 25; index++) + { + blog.Articles.Add(new Article + { + Caption = $"Item {index:D3}" + }); + } + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/articles"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(25); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost" + route); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + } + [Theory] [InlineData(1, 1, 4, null, 2)] [InlineData(2, 1, 4, 1, 3)] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs new file mode 100644 index 0000000000..568f389851 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/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.ReadWrite.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 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/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs new file mode 100644 index 0000000000..b5ffbec5d7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/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.ReadWrite.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/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs new file mode 100644 index 0000000000..f994978bba --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/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.ReadWrite.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 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[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element 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[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs new file mode 100644 index 0000000000..ed706e2fcc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/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.ReadWrite.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 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/ReadWrite/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs new file mode 100644 index 0000000000..0d90d5d002 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/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.ReadWrite.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/ReadWrite/Fetching/FetchRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs new file mode 100644 index 0000000000..fa54ac50f7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs @@ -0,0 +1,246 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Fetching +{ + public sealed class FetchRelationshipTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public FetchRelationshipTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_HasOne_relationship() + { + var workItem = _fakers.WorkItem.Generate(); + workItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("userAccounts"); + responseDocument.SingleData.Id.Should().Be(workItem.Assignee.StringId); + responseDocument.SingleData.Attributes.Should().BeNull(); + } + + [Fact] + public async Task Can_get_empty_HasOne_relationship() + { + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task Can_get_HasMany_relationship() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + userAccount.AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(userAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/userAccounts/{userAccount.StringId}/relationships/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(0).StringId); + item1.Type.Should().Be("workItems"); + item1.Attributes.Should().BeNull(); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(1).StringId); + item2.Type.Should().Be("workItems"); + item2.Attributes.Should().BeNull(); + } + + [Fact] + public async Task Can_get_empty_HasMany_relationship() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(userAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/userAccounts/{userAccount.StringId}/relationships/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task Can_get_HasManyThrough_relationship() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.WorkItemTags = new List + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(0).Tag.StringId); + item1.Type.Should().Be("workTags"); + item1.Attributes.Should().BeNull(); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(1).Tag.StringId); + item2.Type.Should().Be("workTags"); + item2.Attributes.Should().BeNull(); + } + + [Fact] + public async Task Can_get_empty_HasManyThrough_relationship() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_primary_type() + { + var route = "/doesNotExist/99999999/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_primary_ID() + { + var route = "/workItems/99999999/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_relationship_type() + { + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs new file mode 100644 index 0000000000..905a9e5f14 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -0,0 +1,371 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Fetching +{ + public sealed class FetchResourceTests + : IClassFixture, WriteDbContext>> + { + private readonly IntegrationTestContext, WriteDbContext> _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public FetchResourceTests(IntegrationTestContext, WriteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + var workItems = _fakers.WorkItem.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WorkItems.AddRange(workItems); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == workItems[0].StringId); + item1.Type.Should().Be("workItems"); + item1.Attributes["description"].Should().Be(workItems[0].Description); + item1.Attributes["dueAt"].Should().BeCloseTo(workItems[0].DueAt); + item1.Attributes["priority"].Should().Be(workItems[0].Priority.ToString("G")); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItems[1].StringId); + item2.Type.Should().Be("workItems"); + item2.Attributes["description"].Should().Be(workItems[1].Description); + item2.Attributes["dueAt"].Should().BeCloseTo(workItems[1].DueAt); + item2.Attributes["priority"].Should().Be(workItems[1].Priority.ToString("G")); + } + + [Fact] + public async Task Cannot_get_primary_resources_for_unknown_type() + { + // Arrange + var route = "/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = "/workItems/" + workItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(workItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); + responseDocument.SingleData.Attributes["dueAt"].Should().BeCloseTo(workItem.DueAt); + responseDocument.SingleData.Attributes["priority"].Should().Be(workItem.Priority.ToString("G")); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_type() + { + // Arrange + var route = "/doesNotExist/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_ID() + { + // Arrange + var route = "/workItems/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Can_get_secondary_HasOne_resource() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("userAccounts"); + responseDocument.SingleData.Id.Should().Be(workItem.Assignee.StringId); + responseDocument.SingleData.Attributes["firstName"].Should().Be(workItem.Assignee.FirstName); + responseDocument.SingleData.Attributes["lastName"].Should().Be(workItem.Assignee.LastName); + } + + [Fact] + public async Task Can_get_unknown_secondary_HasOne_resource() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task Can_get_secondary_HasMany_resources() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + userAccount.AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(userAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/userAccounts/{userAccount.StringId}/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(0).StringId); + item1.Type.Should().Be("workItems"); + item1.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(0).Description); + item1.Attributes["dueAt"].Should().BeCloseTo(userAccount.AssignedItems.ElementAt(0).DueAt); + item1.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(0).Priority.ToString("G")); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(1).StringId); + item2.Type.Should().Be("workItems"); + item2.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(1).Description); + item2.Attributes["dueAt"].Should().BeCloseTo(userAccount.AssignedItems.ElementAt(1).DueAt); + item2.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(1).Priority.ToString("G")); + } + + [Fact] + public async Task Can_get_unknown_secondary_HasMany_resource() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(userAccount); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/userAccounts/{userAccount.StringId}/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task Can_get_secondary_HasManyThrough_resources() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.WorkItemTags = new List + { + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTags.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(0).Tag.StringId); + item1.Type.Should().Be("workTags"); + item1.Attributes["text"].Should().Be(workItem.WorkItemTags.ElementAt(0).Tag.Text); + item1.Attributes["isBuiltIn"].Should().Be(workItem.WorkItemTags.ElementAt(0).Tag.IsBuiltIn); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(1).Tag.StringId); + item2.Type.Should().Be("workTags"); + item2.Attributes["text"].Should().Be(workItem.WorkItemTags.ElementAt(1).Tag.Text); + item2.Attributes["isBuiltIn"].Should().Be(workItem.WorkItemTags.ElementAt(1).Tag.IsBuiltIn); + } + + [Fact] + public async Task Can_get_unknown_secondary_HasManyThrough_resources() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_primary_type() + { + // Arrange + var route = "/doesNotExist/99999999/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() + { + // Arrange + var route = "/workItems/99999999/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_secondary_type() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(workItem); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/workItems/{workItem.StringId}/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested relationship does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColor.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColor.cs new file mode 100644 index 0000000000..d44e600f49 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColor.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + public sealed class RgbColor : Identifiable + { + [Attr] + public string DisplayName { get; set; } + + // TODO: @ThisPR 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/ReadWrite/RgbColorsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColorsController.cs new file mode 100644 index 0000000000..9d8e32bc25 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/RgbColorsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + public sealed class RgbColorsController : JsonApiController + { + public RgbColorsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs new file mode 100644 index 0000000000..9c9d9068e2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -0,0 +1,767 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.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 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); + }); + } + + [Fact] + public async Task Can_add_self_to_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Children = _fakers.WorkItem.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/children"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(2); + workItemInDatabase.Children.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.Children[0].Id); + workItemInDatabase.Children.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.Id); + }); + } + + [Fact] + public async Task Can_add_self_to_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.RelatedToItems = new List + { + new WorkItemToWorkItem + { + ToItem = _fakers.WorkItem.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/relatedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedToItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedToItems.Should().HaveCount(2); + workItemInDatabase.RelatedToItems.Should().OnlyContain(workItemToWorkItem => workItemToWorkItem.FromItem.Id == existingWorkItem.Id); + workItemInDatabase.RelatedToItems.Should().ContainSingle(workItemToWorkItem => workItemToWorkItem.ToItem.Id == existingWorkItem.Id); + workItemInDatabase.RelatedToItems.Should().ContainSingle(workItemToWorkItem => workItemToWorkItem.ToItem.Id == existingWorkItem.RelatedToItems[0].ToItem.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs new file mode 100644 index 0000000000..5a3e99bfbc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -0,0 +1,770 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.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 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.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(0).Id); + }); + } + + [Fact] + public async Task Can_remove_self_from_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Children = _fakers.WorkItem.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Children.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/children"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Children[0].Id); + }); + } + + [Fact] + public async Task Can_remove_self_from_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.RelatedFromItems = new List + { + new WorkItemToWorkItem + { + FromItem = _fakers.WorkItem.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.RelatedFromItems.Add(new WorkItemToWorkItem + { + FromItem = existingWorkItem + }); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/relatedFrom"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedFromItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.FromItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedFromItems.Should().HaveCount(1); + workItemInDatabase.RelatedFromItems[0].FromItem.Id.Should().Be(existingWorkItem.RelatedFromItems[0].FromItem.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..ffd4a2ee13 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,909 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.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 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: <<"); + } + + [Fact] + public async Task Can_clear_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Children = new List + { + existingWorkItem + }; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/children"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_clear_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.RelatedFromItems = new List + { + new WorkItemToWorkItem + { + FromItem = existingWorkItem + } + }; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/relatedFrom"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedFromItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.FromItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedFromItems.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_assign_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/children"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Id); + }); + } + + [Fact] + public async Task Can_assign_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/relatedTo"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedToItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedToItems.Should().HaveCount(1); + workItemInDatabase.RelatedToItems[0].FromItem.Id.Should().Be(existingWorkItem.Id); + workItemInDatabase.RelatedToItems[0].ToItem.Id.Should().Be(existingWorkItem.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..5f87c16863 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -0,0 +1,634 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.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 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'."); + } + + [Fact] + public async Task Can_clear_cyclic_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Parent = existingWorkItem; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/parent"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_assign_cyclic_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/parent"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..bf183e57c0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,1065 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.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 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[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element 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[] element for to-many relationship."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); + } + + [Fact] + public async Task Can_clear_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Children = new List + { + existingWorkItem + }; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + children = new + { + data = new object[0] + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_clear_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.RelatedFromItems = new List + { + new WorkItemToWorkItem + { + FromItem = existingWorkItem + } + }; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + relatedFrom = new + { + data = new object[0] + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedFromItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.FromItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedFromItems.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_assign_cyclic_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + children = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Children) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Id); + }); + } + + [Fact] + public async Task Can_assign_cyclic_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + relatedTo = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.RelatedToItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.RelatedToItems.Should().HaveCount(1); + workItemInDatabase.RelatedToItems[0].FromItem.Id.Should().Be(existingWorkItem.Id); + workItemInDatabase.RelatedToItems[0].ToItem.Id.Should().Be(existingWorkItem.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs new file mode 100644 index 0000000000..223ee17267 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -0,0 +1,1172 @@ +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.ReadWrite.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 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.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: Resource ID is read-only."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource ID is read-only. - Request body: <<"); + } + + [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); + }); + } + + [Fact] + public async Task Can_update_resource_with_multiple_cyclic_relationship_types() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Parent = _fakers.WorkItem.Generate(); + existingWorkItem.Children = _fakers.WorkItem.Generate(1); + existingWorkItem.RelatedToItems = new List + { + new WorkItemToWorkItem + { + ToItem = _fakers.WorkItem.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + parent = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }, + children = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }, + relatedTo = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .Include(workItem => workItem.Children) + .Include(workItem => workItem.RelatedToItems) + .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); + + workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children.Single().Id.Should().Be(existingWorkItem.Id); + + workItemInDatabase.RelatedToItems.Should().HaveCount(1); + workItemInDatabase.RelatedToItems.Single().ToItem.Id.Should().Be(existingWorkItem.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..c3636344fe --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -0,0 +1,765 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.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 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: <<"); + } + + [Fact] + public async Task Can_clear_cyclic_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + + existingWorkItem.Parent = existingWorkItem; + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + parent = new + { + data = (object)null + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_assign_cyclic_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + parent = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var workItemInDatabase = await dbContext.WorkItems + .Include(workItem => workItem.Parent) + .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccount.cs new file mode 100644 index 0000000000..1ed7034038 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccount.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + 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/ReadWrite/UserAccountsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccountsController.cs new file mode 100644 index 0000000000..9d409bbb57 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/UserAccountsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + public sealed class UserAccountsController : JsonApiController + { + public UserAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs new file mode 100644 index 0000000000..13f09d4725 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItem.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + 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 WorkItem Parent { get; set; } + + [HasMany] + public IList Children { get; set; } + + [NotMapped] + [HasManyThrough(nameof(RelatedFromItems), LeftPropertyName = nameof(WorkItemToWorkItem.ToItem), RightPropertyName = nameof(WorkItemToWorkItem.FromItem))] + public IList RelatedFrom { get; set; } + public IList RelatedFromItems { get; set; } + + [NotMapped] + [HasManyThrough(nameof(RelatedToItems), LeftPropertyName = nameof(WorkItemToWorkItem.FromItem), RightPropertyName = nameof(WorkItemToWorkItem.ToItem))] + public IList RelatedTo { get; set; } + public IList RelatedToItems { get; set; } + + [HasOne] + public WorkItemGroup Group { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs new file mode 100644 index 0000000000..4eadea345c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/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.ReadWrite +{ + 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/ReadWrite/WorkItemGroupsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs new file mode 100644 index 0000000000..c6b00f25e3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemGroupsController : JsonApiController + { + public WorkItemGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs new file mode 100644 index 0000000000..baa810f7c0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + public enum WorkItemPriority + { + Low, + Medium, + High + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs new file mode 100644 index 0000000000..d9c13d9e4c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + 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/ReadWrite/WorkItemToWorkItem.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs new file mode 100644 index 0000000000..5d2eb588e8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemToWorkItem + { + public WorkItem FromItem { get; set; } + public int FromItemId { get; set; } + + public WorkItem ToItem { get; set; } + public int ToItemId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs new file mode 100644 index 0000000000..e3e90fe0f3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemsController : JsonApiController + { + public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkTag.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkTag.cs new file mode 100644 index 0000000000..04ef9d3d95 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WorkTag.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkTag : Identifiable + { + [Attr] + public string Text { get; set; } + + [Attr] + public bool IsBuiltIn { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteDbContext.cs new file mode 100644 index 0000000000..08f66e8d5a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteDbContext.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + 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}); + + builder.Entity() + .HasKey(item => new { item.FromItemId, item.ToItemId}); + + builder.Entity() + .HasOne(workItemToWorkItem => workItemToWorkItem.FromItem) + .WithMany(workItem => workItem.RelatedToItems) + .HasForeignKey(workItemToWorkItem => workItemToWorkItem.FromItemId); + + builder.Entity() + .HasOne(workItemToWorkItem => workItemToWorkItem.ToItem) + .WithMany(workItem => workItem.RelatedFromItems) + .HasForeignKey(workItemToWorkItem => workItemToWorkItem.ToItemId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteFakers.cs new file mode 100644 index 0000000000..ce2ec19eda --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/WriteFakers.cs @@ -0,0 +1,45 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite +{ + internal sealed class WriteFakers : FakerContainer + { + 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; + } +} 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..7aaf444c8c 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" } } }; 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..9d6a522ae1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -20,13 +20,11 @@ public ResultCapturingRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, ResourceCaptureStore captureStore) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, - constraintProviders, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, 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/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..82aae80866 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -4,6 +4,8 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; @@ -54,7 +56,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] @@ -135,6 +136,38 @@ public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces( Assert.Throws(() => services.AddResourceService()); } + [Fact] + public void AddResourceRepository_Registers_All_Shorthand_Repository_Interfaces() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddResourceRepository(); + + // Assert + var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService(typeof(IResourceRepository))); + Assert.IsType(provider.GetRequiredService(typeof(IResourceReadRepository))); + Assert.IsType(provider.GetRequiredService(typeof(IResourceWriteRepository))); + } + + [Fact] + public void AddResourceRepository_Registers_All_LongForm_Repository_Interfaces() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddResourceRepository(); + + // Assert + var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService(typeof(IResourceRepository))); + Assert.IsType(provider.GetRequiredService(typeof(IResourceReadRepository))); + Assert.IsType(provider.GetRequiredService(typeof(IResourceWriteRepository))); + } + [Fact] public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified() { @@ -158,30 +191,59 @@ public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified( public sealed class IntResource : Identifiable { } public class GuidResource : Identifiable { } - private class IntResourceService : IResourceService + private sealed class IntResourceService : IResourceService { - public Task CreateAsync(IntResource resource) => throw new NotImplementedException(); - public Task DeleteAsync(int id) => throw new NotImplementedException(); public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(int id) => throw new NotImplementedException(); public Task GetSecondaryAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task 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 CreateAsync(IntResource resource) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task UpdateAsync(int id, IntResource resource) => throw new NotImplementedException(); + public Task SetRelationshipAsync(int primaryId, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); + public Task DeleteAsync(int id) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(int primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } - private class GuidResourceService : IResourceService + private sealed class GuidResourceService : IResourceService { - public Task CreateAsync(GuidResource resource) => throw new NotImplementedException(); - public Task DeleteAsync(Guid id) => throw new NotImplementedException(); public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(Guid id) => throw new NotImplementedException(); public Task GetSecondaryAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task 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 CreateAsync(GuidResource resource) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task UpdateAsync(Guid id, GuidResource resource) => throw new NotImplementedException(); + public Task SetRelationshipAsync(Guid primaryId, string relationshipName, object secondaryResourceIds) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(Guid primaryId, string relationshipName, ISet secondaryResourceIds) => throw new NotImplementedException(); } + private sealed class IntResourceRepository : IResourceRepository + { + public Task> GetAsync(QueryLayer layer) => throw new NotImplementedException(); + public Task CountAsync(FilterExpression topFilter) => throw new NotImplementedException(); + public Task CreateAsync(IntResource resource) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(int primaryId, ISet secondaryResourceIds, FilterExpression joinTableFilter) => throw new NotImplementedException(); + public Task UpdateAsync(IntResource resourceFromRequest, IntResource resourceFromDatabase) => throw new NotImplementedException(); + public Task SetRelationshipAsync(IntResource primaryResource, object secondaryResourceIds) => throw new NotImplementedException(); + public Task DeleteAsync(int id) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(IntResource primaryResource, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task GetForUpdateAsync(QueryLayer queryLayer) => throw new NotImplementedException(); + } + + private sealed class GuidResourceRepository : IResourceRepository + { + public Task> GetAsync(QueryLayer layer) => throw new NotImplementedException(); + public Task CountAsync(FilterExpression topFilter) => throw new NotImplementedException(); + public Task CreateAsync(GuidResource resource) => throw new NotImplementedException(); + public Task AddToToManyRelationshipAsync(Guid primaryId, ISet secondaryResourceIds, FilterExpression joinTableFilter) => throw new NotImplementedException(); + public Task UpdateAsync(GuidResource resourceFromRequest, GuidResource resourceFromDatabase) => throw new NotImplementedException(); + public Task SetRelationshipAsync(GuidResource primaryResource, object secondaryResourceIds) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id) => throw new NotImplementedException(); + public Task RemoveFromToManyRelationshipAsync(GuidResource primaryResource, ISet secondaryResourceIds) => throw new NotImplementedException(); + public Task GetForUpdateAsync(QueryLayer queryLayer) => throw new NotImplementedException(); + } public class TestContext : DbContext { 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..017e61fcb1 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -172,8 +172,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 +205,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 +243,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); } @@ -369,13 +366,14 @@ private IResourceReadRepository CreateTestRepository(AppDbC { var serviceProvider = ((IInfrastructure) dbContext).Instance; var resourceFactory = new ResourceFactory(serviceProvider); - IDbContextResolver resolver = CreateTestDbResolver(dbContext); - var serviceFactory = new Mock().Object; + IDbContextResolver resolver = CreateTestDbResolver(dbContext); var targetedFields = new TargetedFields(); - return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, serviceFactory, resourceFactory, new List(), NullLoggerFactory.Instance); + + return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, resourceFactory, + new List(), NullLoggerFactory.Instance); } - private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) where TModel : class, IIdentifiable + private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) { var mock = new Mock(); mock.Setup(r => r.GetContext()).Returns(dbContext); @@ -385,7 +383,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 InverseNavigationResolver(_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..5869b76cff 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; @@ -75,8 +76,14 @@ private JsonApiResourceService GetService() var serviceProvider = new ServiceContainer(); var resourceFactory = new ResourceFactory(serviceProvider); var resourceDefinitionAccessor = new Mock().Object; + var resourceRepositoryAccessor = new Mock().Object; var paginationContext = new PaginationContext(); - var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext); + var targetedFields = new Mock().Object; + var resourceContextProvider = new Mock().Object; + var resourceHookExecutor = new NeverResourceHookExecutorFacade(); + var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionAccessor, options, paginationContext, targetedFields); + var dataStoreUpdateFailureInspector = new SecondaryResourceResolver(resourceContextProvider, targetedFields, composer, resourceRepositoryAccessor); + var request = new JsonApiRequest { PrimaryResource = _resourceGraph.GetResourceContext(), @@ -86,7 +93,8 @@ private JsonApiResourceService GetService() }; return new JsonApiResourceService(_repositoryMock.Object, composer, paginationContext, options, - NullLoggerFactory.Instance, request, changeTracker, resourceFactory, null); + NullLoggerFactory.Instance, request, changeTracker, resourceFactory, dataStoreUpdateFailureInspector, + resourceHookExecutor); } } }